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:
James Feng 2026-06-03 13:43:38 +08:00
parent fe83eaa09d
commit bad81fdfcc
8 changed files with 120 additions and 19 deletions

View File

@ -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"),

View File

@ -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}`}`);

View File

@ -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
}

View File

@ -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)}`)
}
}
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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
View 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
}