Phase 1: security do-now — redaction helpers + RCS defaults hardened
- Add src/utils/sensitive.ts: redactUrl/redactValue/redactForLog - Apply redaction to MCP config printing (mcp.tsx) - Apply redaction to hard-fail logging (log.ts) - Apply redaction to chrome native host debug log - Apply redaction to CLI error output (exit.ts) - RCS: default bind to 127.0.0.1 instead of 0.0.0.0 - RCS: CORS restricted to localhost + baseUrl whitelist - pipeTransport: default bind to 127.0.0.1 via PIPE_HOST env
This commit is contained in:
parent
fe83eaa09d
commit
bad81fdfcc
|
|
@ -1,7 +1,7 @@
|
|||
export const config = {
|
||||
version: process.env.RCS_VERSION || "0.1.0",
|
||||
port: parseInt(process.env.RCS_PORT || "3000"),
|
||||
host: process.env.RCS_HOST || "0.0.0.0",
|
||||
host: process.env.RCS_HOST || "127.0.0.1",
|
||||
apiKeys: (process.env.RCS_API_KEYS || "").split(",").filter(Boolean),
|
||||
baseUrl: process.env.RCS_BASE_URL || "",
|
||||
pollTimeout: parseInt(process.env.RCS_POLL_TIMEOUT || "8"),
|
||||
|
|
|
|||
|
|
@ -25,10 +25,25 @@ import webEnvironments from "./routes/web/environments";
|
|||
console.log("[RCS] In-memory store ready (no SQLite)");
|
||||
|
||||
const app = new Hono();
|
||||
const port = config.port;
|
||||
const host = config.host;
|
||||
|
||||
const allowedWebOrigins = [
|
||||
`http://localhost:${port}`,
|
||||
`http://127.0.0.1:${port}`,
|
||||
config.baseUrl,
|
||||
].filter(Boolean);
|
||||
|
||||
// Middleware
|
||||
app.use("*", logger());
|
||||
app.use("/web/*", cors());
|
||||
app.use(
|
||||
"/web/*",
|
||||
cors({
|
||||
origin: (origin) =>
|
||||
allowedWebOrigins.includes(origin) ? origin : undefined,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
||||
|
|
@ -70,9 +85,6 @@ app.route("/web", webSessions);
|
|||
app.route("/web", webControl);
|
||||
app.route("/web", webEnvironments);
|
||||
|
||||
const port = config.port;
|
||||
const host = config.host;
|
||||
|
||||
console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
|
||||
console.log("[RCS] API key configuration loaded");
|
||||
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
*/
|
||||
/* eslint-disable custom-rules/no-process-exit -- centralized CLI exit point */
|
||||
|
||||
import { redactForLog } from '../utils/sensitive.js'
|
||||
|
||||
// `return undefined as never` (not a post-exit throw) — tests spy on
|
||||
// process.exit and let it return. Call sites write `return cliError(...)`
|
||||
// where subsequent code would dereference narrowed-away values under mock.
|
||||
|
|
@ -18,7 +20,7 @@
|
|||
/** Write an error message to stderr (if given) and exit with code 1. */
|
||||
export function cliError(msg?: string): never {
|
||||
// biome-ignore lint/suspicious/noConsole: centralized CLI error output
|
||||
if (msg) console.error(msg)
|
||||
if (msg) console.error(redactForLog(msg))
|
||||
process.exit(1)
|
||||
return undefined as never
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import { isFsInaccessible } from '../../utils/errors.js'
|
|||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
|
||||
import { safeParseJSON } from '../../utils/json.js'
|
||||
import { getPlatform } from '../../utils/platform.js'
|
||||
import { redactUrl, redactValue } from '../../utils/sensitive.js'
|
||||
import { cliError, cliOk } from '../exit.js'
|
||||
|
||||
async function checkMcpServerHealth(
|
||||
|
|
@ -214,13 +215,13 @@ export async function mcpListHandler(): Promise<void> {
|
|||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
||||
console.log(`${name}: ${redactUrl(server.url)} (SSE) - ${status}`)
|
||||
} else if (server.type === 'http') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
||||
console.log(`${name}: ${redactUrl(server.url)} (HTTP) - ${status}`)
|
||||
} else if (server.type === 'claudeai-proxy') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} - ${status}`)
|
||||
console.log(`${name}: ${redactUrl(server.url)} - ${status}`)
|
||||
} else if (!server.type || server.type === 'stdio') {
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string }
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
||||
|
|
@ -259,13 +260,13 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
|||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: sse`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
console.log(` URL: ${redactUrl(server.url)}`)
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
console.log(` ${redactValue(key)}: ${redactValue(key, value)}`)
|
||||
}
|
||||
}
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
|
|
@ -284,13 +285,13 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
|||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: http`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
console.log(` URL: ${redactUrl(server.url)}`)
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
console.log(` ${redactValue(key)}: ${redactValue(key, value)}`)
|
||||
}
|
||||
}
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
|
|
@ -318,7 +319,7 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
|||
console.log(' Environment:')
|
||||
for (const [key, value] of Object.entries(server.env)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}=${value}`)
|
||||
console.log(` ${redactValue(key)}=${redactValue(key, value)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { homedir, platform } from 'os'
|
|||
import { join } from 'path'
|
||||
import { z } from 'zod'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { redactForLog } from '../sensitive.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import { getSecureSocketPath, getSocketDir } from './common.js'
|
||||
|
||||
|
|
@ -32,17 +33,18 @@ const LOG_FILE =
|
|||
: undefined
|
||||
|
||||
function log(message: string, ...args: unknown[]): void {
|
||||
const redactedMessage = redactForLog(message)
|
||||
if (LOG_FILE) {
|
||||
const timestamp = new Date().toISOString()
|
||||
const formattedArgs = args.length > 0 ? ' ' + jsonStringify(args) : ''
|
||||
const logLine = `[${timestamp}] [Claude Chrome Native Host] ${message}${formattedArgs}\n`
|
||||
const logLine = `[${timestamp}] [Claude Chrome Native Host] ${redactedMessage}${formattedArgs}\n`
|
||||
// Fire-and-forget: logging is best-effort and callers (including event
|
||||
// handlers) don't await
|
||||
void appendFile(LOG_FILE, logLine).catch(() => {
|
||||
// Ignore file write errors
|
||||
})
|
||||
}
|
||||
console.error(`[Claude Chrome Native Host] ${message}`, ...args)
|
||||
console.error(`[Claude Chrome Native Host] ${redactedMessage}`, ...args)
|
||||
}
|
||||
/**
|
||||
* Send a message to stdout (Chrome native messaging protocol)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { isEnvTruthy } from './envUtils.js'
|
|||
import { toError, shortErrorStack } from './errors.js'
|
||||
import { isEssentialTrafficOnly } from './privacyLevel.js'
|
||||
import { jsonParse } from './slowOperations.js'
|
||||
import { redactForLog } from './sensitive.js'
|
||||
|
||||
/**
|
||||
* Gets the display title for a log/session with fallback logic.
|
||||
|
|
@ -159,7 +160,10 @@ export function logError(error: unknown): void {
|
|||
const err = toError(error)
|
||||
if (feature('HARD_FAIL') && isHardFailMode()) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional crash output
|
||||
console.error('[HARD FAIL] logError called with:', err.stack || err.message)
|
||||
console.error(
|
||||
'[HARD FAIL] logError called with:',
|
||||
redactForLog(err.stack || err.message),
|
||||
)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,12 +262,13 @@ export class PipeServer extends EventEmitter {
|
|||
*/
|
||||
private async startTcpServer(port: number): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const host = process.env.PIPE_HOST || '127.0.0.1'
|
||||
this.tcpServer = createServer(socket => this.setupSocket(socket))
|
||||
this.tcpServer.on('error', reject)
|
||||
this.tcpServer.listen(port, '0.0.0.0', () => {
|
||||
this.tcpServer.listen(port, host, () => {
|
||||
const addr = this.tcpServer!.address()
|
||||
if (addr && typeof addr === 'object') {
|
||||
this._tcpAddress = { host: '0.0.0.0', port: addr.port }
|
||||
this._tcpAddress = { host, port: addr.port }
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
|
|
|||
79
src/utils/sensitive.ts
Normal file
79
src/utils/sensitive.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
const REDACTED = '[REDACTED]'
|
||||
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/(api[_-]?key|auth(?:orization)?|bearer|cookie|credential|password|secret|session|token)/i
|
||||
|
||||
const SENSITIVE_QUERY_PARAMS = new Set([
|
||||
'access_token',
|
||||
'api_key',
|
||||
'apikey',
|
||||
'auth',
|
||||
'authorization',
|
||||
'client_secret',
|
||||
'code',
|
||||
'credential',
|
||||
'key',
|
||||
'password',
|
||||
'refresh_token',
|
||||
'secret',
|
||||
'session',
|
||||
'token',
|
||||
])
|
||||
|
||||
const TOKEN_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\b(sk-ant-[A-Za-z0-9_-]{10,})\b/g, REDACTED],
|
||||
[/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, REDACTED],
|
||||
[/\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, REDACTED],
|
||||
[/\bnpm_[A-Za-z0-9]{36}\b/g, REDACTED],
|
||||
[/\bxox[baporst]-[A-Za-z0-9-]{10,}\b/g, REDACTED],
|
||||
[/\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/gi, `Bearer ${REDACTED}`],
|
||||
[/\bBasic\s+[A-Za-z0-9+/]+=*\b/gi, `Basic ${REDACTED}`],
|
||||
]
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
return SENSITIVE_KEY_PATTERN.test(key)
|
||||
}
|
||||
|
||||
export function redactUrl(urlString: string): string {
|
||||
try {
|
||||
const parsed = new URL(urlString)
|
||||
if (parsed.username) parsed.username = REDACTED
|
||||
if (parsed.password) parsed.password = REDACTED
|
||||
|
||||
for (const key of [...parsed.searchParams.keys()]) {
|
||||
if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) {
|
||||
parsed.searchParams.set(key, REDACTED)
|
||||
}
|
||||
}
|
||||
|
||||
return redactForLog(parsed.toString())
|
||||
} catch {
|
||||
return redactForLog(urlString)
|
||||
}
|
||||
}
|
||||
|
||||
export function redactValue(key: string, value: unknown): string
|
||||
export function redactValue(value: unknown): string
|
||||
export function redactValue(keyOrValue: unknown, value?: unknown): string {
|
||||
if (arguments.length > 1) {
|
||||
const key = String(keyOrValue)
|
||||
return isSensitiveKey(key) ? REDACTED : redactForLog(String(value ?? ''))
|
||||
}
|
||||
|
||||
return redactForLog(String(keyOrValue ?? ''))
|
||||
}
|
||||
|
||||
export function redactForLog(message: unknown): string {
|
||||
let redacted = String(message ?? '')
|
||||
|
||||
for (const [pattern, replacement] of TOKEN_PATTERNS) {
|
||||
redacted = redacted.replace(pattern, replacement)
|
||||
}
|
||||
|
||||
redacted = redacted.replace(
|
||||
/\b([A-Za-z0-9_.-]*(?:api[_-]?key|auth|authorization|bearer|cookie|credential|password|secret|session|token)[A-Za-z0-9_.-]*)\s*[:=]\s*("[^"]+"|'[^']+'|[^\s,;]+)/gi,
|
||||
(_match, key: string) => `${key}=${REDACTED}`,
|
||||
)
|
||||
|
||||
return redacted
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user