From d637a263505448bf4505b85535babbfaacedbaac Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 13 Feb 2026 11:11:54 -0600 Subject: [PATCH] Gateway: sanitize WebSocket log headers (#15592) --- CHANGELOG.md | 1 + src/gateway/server/ws-connection.ts | 42 ++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a1b7641ee..dde64b522b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1. - Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane. - Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. +- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow. - Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane. - Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. - Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck. diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 070dec98d7..43bda01802 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -7,6 +7,7 @@ import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-me import type { GatewayWsClient } from "./ws-types.js"; import { resolveCanvasHostUrl } from "../../infra/canvas-host-url.js"; import { listSystemPresence, upsertPresence } from "../../infra/system-presence.js"; +import { truncateUtf16Safe } from "../../utils.js"; import { isWebchatClient } from "../../utils/message-channel.js"; import { isLoopbackAddress } from "../net.js"; import { getHandshakeTimeoutMs } from "../server-constants.js"; @@ -17,6 +18,28 @@ import { attachGatewayWsMessageHandler } from "./ws-connection/message-handler.j type SubsystemLogger = ReturnType; +const LOG_HEADER_MAX_LEN = 300; +const LOG_HEADER_CONTROL_REGEX = /[\u0000-\u001f\u007f-\u009f]/g; +const LOG_HEADER_FORMAT_REGEX = /\p{Cf}/gu; + +const sanitizeLogValue = (value: string | undefined): string | undefined => { + if (!value) { + return undefined; + } + const cleaned = value + .replace(LOG_HEADER_CONTROL_REGEX, " ") + .replace(LOG_HEADER_FORMAT_REGEX, " ") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned) { + return undefined; + } + if (cleaned.length <= LOG_HEADER_MAX_LEN) { + return cleaned; + } + return truncateUtf16Safe(cleaned, LOG_HEADER_MAX_LEN); +}; + export function attachGatewayWsConnectionHandler(params: { wss: WebSocketServer; clients: Set; @@ -156,6 +179,11 @@ export function attachGatewayWsConnectionHandler(params: { socket.once("close", (code, reason) => { const durationMs = Date.now() - openedAt; + const logForwardedFor = sanitizeLogValue(forwardedFor); + const logOrigin = sanitizeLogValue(requestOrigin); + const logHost = sanitizeLogValue(requestHost); + const logUserAgent = sanitizeLogValue(requestUserAgent); + const logReason = sanitizeLogValue(reason?.toString()); const closeContext = { cause: closeCause, handshake: handshakeState, @@ -163,10 +191,10 @@ export function attachGatewayWsConnectionHandler(params: { lastFrameType, lastFrameMethod, lastFrameId, - host: requestHost, - origin: requestOrigin, - userAgent: requestUserAgent, - forwardedFor, + host: logHost, + origin: logOrigin, + userAgent: logUserAgent, + forwardedFor: logForwardedFor, ...closeMeta, }; if (!client) { @@ -174,13 +202,13 @@ export function attachGatewayWsConnectionHandler(params: { ? logWsControl.debug : logWsControl.warn; logFn( - `closed before connect conn=${connId} remote=${remoteAddr ?? "?"} fwd=${forwardedFor ?? "n/a"} origin=${requestOrigin ?? "n/a"} host=${requestHost ?? "n/a"} ua=${requestUserAgent ?? "n/a"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`, + `closed before connect conn=${connId} remote=${remoteAddr ?? "?"} fwd=${logForwardedFor || "n/a"} origin=${logOrigin || "n/a"} host=${logHost || "n/a"} ua=${logUserAgent || "n/a"} code=${code ?? "n/a"} reason=${logReason || "n/a"}`, closeContext, ); } if (client && isWebchatClient(client.connect.client)) { logWsControl.info( - `webchat disconnected code=${code} reason=${reason?.toString() || "n/a"} conn=${connId}`, + `webchat disconnected code=${code} reason=${logReason || "n/a"} conn=${connId}`, ); } if (client?.presenceKey) { @@ -208,7 +236,7 @@ export function attachGatewayWsConnectionHandler(params: { logWs("out", "close", { connId, code, - reason: reason?.toString(), + reason: logReason, durationMs, cause: closeCause, handshake: handshakeState,