diff --git a/packages/remote-control-server/src/config.ts b/packages/remote-control-server/src/config.ts index cd7716032..3469bb6eb 100644 --- a/packages/remote-control-server/src/config.ts +++ b/packages/remote-control-server/src/config.ts @@ -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"), diff --git a/packages/remote-control-server/src/index.ts b/packages/remote-control-server/src/index.ts index 309f29889..d6bad0684 100644 --- a/packages/remote-control-server/src/index.ts +++ b/packages/remote-control-server/src/index.ts @@ -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}`}`); diff --git a/src/cli/exit.ts b/src/cli/exit.ts index 99e56f97b..2dd3d1374 100644 --- a/src/cli/exit.ts +++ b/src/cli/exit.ts @@ -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 } diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index ab543af11..554374824 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -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 { // 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 { // 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 { // 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 { 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)}`) } } } diff --git a/src/utils/claudeInChrome/chromeNativeHost.ts b/src/utils/claudeInChrome/chromeNativeHost.ts index 4052a6064..f3556e422 100644 --- a/src/utils/claudeInChrome/chromeNativeHost.ts +++ b/src/utils/claudeInChrome/chromeNativeHost.ts @@ -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) diff --git a/src/utils/log.ts b/src/utils/log.ts index 405afaf4d..dc9c915e3 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -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) } diff --git a/src/utils/pipeTransport.ts b/src/utils/pipeTransport.ts index 3d1974e33..d697e3073 100644 --- a/src/utils/pipeTransport.ts +++ b/src/utils/pipeTransport.ts @@ -262,12 +262,13 @@ export class PipeServer extends EventEmitter { */ private async startTcpServer(port: number): Promise { return new Promise((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() }) diff --git a/src/utils/sensitive.ts b/src/utils/sensitive.ts new file mode 100644 index 000000000..137861834 --- /dev/null +++ b/src/utils/sensitive.ts @@ -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 +}