178 lines
8.5 KiB
Markdown
178 lines
8.5 KiB
Markdown
---
|
|
title: "Error Message Sanitization"
|
|
version: 3.8.40
|
|
lastUpdated: 2026-06-28
|
|
---
|
|
|
|
# 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-06-28 — v3.8.40
|
|
> **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 ≥60% statements/lines/functions/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_
|