Spaces:
Runtime error
Runtime error
| title: "Error Message Sanitization" | |
| version: 3.8.2 | |
| lastUpdated: 2026-05-14 | |
| # Error Message Sanitization | |
| > **Source of truth:** `open-sse/utils/error.ts` β `sanitizeErrorMessage`, `buildErrorBody`, `createErrorResult` | |
| > **Tests:** `tests/unit/error-message-sanitization.test.ts` | |
| > **Last updated:** 2026-05-14 β v3.8.0 | |
| > **Audience:** Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers). | |
| > **Status:** **MANDATORY** for every code path that returns an error message to a client. | |
| ## Why this exists | |
| CodeQL rule `js/stack-trace-exposure` (CWE-209) flags any code path where an error message originating from a runtime exception reaches an HTTP / SSE response without being sanitized. Stack traces and absolute file paths in production responses give attackers: | |
| - Internal directory layout (`/srv/app/src/lib/...`) β reconnaissance for further attacks. | |
| - Library / framework versions inferred from stack frames β targeted exploit selection. | |
| - Sensitive runtime values that may be string-interpolated into errors (DB queries, config values). | |
| The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both classes of leakage: | |
| 1. Multi-line stack traces β only the first line (the actual error message) is kept. | |
| 2. Absolute paths (`/...*.{ts,js,tsx,jsx,mjs,cjs}[:line[:col]]` and `C:\...`) β replaced with `<path>`. | |
| ## The mandatory pattern | |
| ### 1. Building an error response (HTTP / API routes) | |
| Use `buildErrorBody()` β sanitization is built-in: | |
| ```ts | |
| import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; | |
| export async function POST(req: Request) { | |
| try { | |
| // ... handler logic ... | |
| } catch (err) { | |
| return new Response(JSON.stringify(buildErrorBody(500, String(err))), { | |
| status: 500, | |
| headers: { "Content-Type": "application/json" }, | |
| }); | |
| } | |
| } | |
| ``` | |
| Or, for the convenience wrappers in the same module: | |
| ```ts | |
| import { | |
| errorResponse, // one-shot Response object | |
| writeStreamError, // SSE writer | |
| createErrorResult, // { success: false, status, response, ... } shape | |
| unavailableResponse, // adds Retry-After | |
| providerCircuitOpenResponse, | |
| modelCooldownResponse, | |
| } from "@omniroute/open-sse/utils/error.ts"; | |
| ``` | |
| All of these route through `buildErrorBody` and therefore through `sanitizeErrorMessage`. **You never need to call `sanitizeErrorMessage` manually** when using these helpers. | |
| ### 2. Custom error envelopes (rare) | |
| When you can't use the helpers above (e.g. the response shape is dictated by an upstream protocol like Connect-RPC), import `sanitizeErrorMessage` directly: | |
| ```ts | |
| import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; | |
| const body = JSON.stringify({ | |
| error: { | |
| message: sanitizeErrorMessage(rawMessage), | |
| type: "invalid_request_error", | |
| code: "", | |
| }, | |
| }); | |
| ``` | |
| This is the only sanctioned way to assemble a custom error body. See `open-sse/executors/cursor.ts::buildErrorResponse` for the reference implementation. | |
| ### 3. Logging vs. responding | |
| `sanitizeErrorMessage` should **only** wrap the value that crosses the network boundary. Internal logs (`pino`, `console`) should keep the full message, including stack, so operators can debug. Pattern: | |
| ```ts | |
| try { | |
| // ... | |
| } catch (err) { | |
| log.error({ err }, "handler failed"); // full err with stack β internal log | |
| return errorResponse(500, getErrorMessage(err)); // sanitized β sent to client | |
| } | |
| ``` | |
| ### 4. Forbidden patterns | |
| β **Never** put raw exception output in a Response body: | |
| ```ts | |
| // BAD: stack trace + file paths reach the client | |
| return new Response(JSON.stringify({ error: { message: err.stack || err.message } }), { | |
| status: 500, | |
| }); | |
| ``` | |
| β **Never** roll your own first-line splitter: | |
| ```ts | |
| // BAD: forgets to strip absolute paths, may drift from the canonical helper | |
| const safe = String(err).split("\n")[0]; | |
| ``` | |
| β **Never** sanitize in the route and forget the SSE path. Anything that writes to a stream goes through `writeStreamError` (or its underlying `buildErrorBody`). | |
| β **Never** include `process.cwd()`, `__filename`, `__dirname`, env-derived paths in error messages β they bypass the path regex and reveal the deployment topology. | |
| ## Coverage in CI | |
| `tests/unit/error-message-sanitization.test.ts` enforces: | |
| - Every route under `/api/model-combo-mappings/*` returns sanitized bodies on 4xx/5xx. | |
| - `sanitizeErrorMessage` strips multi-line stack traces. | |
| - `sanitizeErrorMessage` replaces POSIX and Windows absolute paths with `<path>`. | |
| - `sanitizeErrorMessage` handles `null`/`undefined`/`Error` instance inputs safely. | |
| - `buildErrorBody` never exposes stack traces in its `message` field. | |
| When adding a new route or executor, copy the assertion pattern from this file. The coverage gate (`npm run test:coverage`) enforces β₯75% statements/lines/functions and β₯70% branches β error paths must be covered. | |
| ## Related controls | |
| - `js/stack-trace-exposure` CodeQL alerts in `.github/security` should always be **either** fixed via these helpers **or** dismissed with a comment citing this doc. | |
| - The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles structured log redaction separately. This doc covers only the response-message surface. | |
| - Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage β keep both files aligned when adding a new exfiltration concern. | |
| ## Upstream details passthrough | |
| `buildErrorBody` accepts an optional third argument `upstreamDetails` (raw | |
| parsed body from the upstream provider). When provided, it is sanitized by | |
| `sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`. | |
| Sanitization rules applied to `upstreamDetails`: | |
| 1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths). | |
| 2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i` | |
| are removed. | |
| 3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`. | |
| 4. Arrays are capped at 32 elements. | |
| Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pass | |
| `upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content, | |
| guardrail blocks) do not include `upstream_details`. | |
| Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to | |
| `upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)` | |
| without an upstream body. | |
| ## Known CodeQL limitation: custom sanitizers not recognized | |
| The CodeQL query [`js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) uses a fixed allowlist of sanitizer patterns (e.g. inline `.split("\n")[0]`, `String#replace` with specific regex shapes, access to `.message` on `Error`). It does **not** recognize indirection through a custom helper like our `sanitizeErrorMessage()`. | |
| This means callsites that demonstrably sanitize via this module β for example `open-sse/utils/error.ts::errorResponse` and `open-sse/executors/cursor.ts::buildErrorResponse` β may continue to raise the alert even though the code is functionally safe. Precedent dismissals: `#224`, `#231` (May 2026), both marked `false positive` with technical justification. | |
| **How to handle a new occurrence:** | |
| 1. Confirm the callsite actually routes the message through `sanitizeErrorMessage` / `buildErrorBody` / one of the wrappers documented above (read the call chain end-to-end β don't trust a comment). | |
| 2. Confirm `tests/unit/error-message-sanitization.test.ts` exercises the path (or add coverage). | |
| 3. Dismiss the alert via `gh api ... -X PATCH state=dismissed -f 'dismissed_reason=false positive'` referencing this doc. | |
| 4. Do **not** "fix" by inlining `.split("\n")[0]` everywhere β the helper is the single source of truth; duplicating the pattern weakens the sanitizer (loses path scrubbing, length cap, type coercion) for the appearance of placating the scanner. | |
| Adopting opt-in features like CodeQL's [`@codeql/javascript-models` custom sanitizer config](https://codeql.github.com/docs/codeql-language-guides/customizing-library-models-for-javascript/) is the long-term fix; it lives outside this doc. | |
| ## References | |
| - [CWE-209: Information Exposure Through an Error Message](https://cwe.mitre.org/data/definitions/209.html) | |
| - [CodeQL `js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) | |
| - [OWASP: Error Handling Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html) | |
| - Commit centralizing the helper: `1a39c31f` β _fix(security): mask public upstream creds + centralize error sanitization_ | |