Files
OmniRoute/tests/unit/sseHeartbeat.test.ts
Bob.Hou 6b823aa441 fix(logging,sse): redact sensitive log fields and default SSE comments to disabled (#10539)
* fix(logging): redact client IPs and account prefixes by default

ProxyEgress and AUTH logs exposed client IPs, egress IPs, and account
prefixes at info level — a privacy leak in multi-tenant/shared-log
environments. Now redacted by default, only shown when debugMode=true.

Fixes #10348

* fix(sse): default SSE comment lines to disabled

Strict SSE clients (WorkBuddy, etc.) JSON.parse every SSE line and
crash on  comment lines. Changed OMNIROUTE_SSE_COMMENTS
default from enabled to disabled. Operators can opt in with
OMNIROUTE_SSE_COMMENTS=on.

Fixes #10524

* fix(logging): gate AUTH account-prefix redaction on a narrow flag, not debugMode

The proxy-log redaction half of #10348 is superseded by an already-merged
fix (PROXY_LOG_INCLUDE_IPS, decoupled from debugMode). The remaining gap was
the chat.ts AUTH log line ("Using <provider> account: <prefix>..."), which
this PR gated on the broad `debugMode` setting. `debugMode` is a general
dashboard-visibility toggle unrelated to log privacy — coupling redaction to
it means any future, unrelated change to debugMode's default silently
changes whether account prefixes leak into logs.

Add a dedicated AUTH_LOG_INCLUDE_ACCOUNT_ID feature flag (default off,
security category) and gate the AUTH log line on it via
isFeatureFlagEnabled(), which reads the DB override synchronously on every
call (no stale in-memory cache to invalidate) and fails safe to redacted on
any lookup error.

Also update the SSE-comments tests/docs that still asserted the old
enabled-by-default behavior (tests/unit/sseHeartbeat.test.ts,
tests/unit/sse-comments-optout-9305.test.ts, docs/reference/ENVIRONMENT.md)
to match the new default-off behavior from this PR's earlier commit.

Refs #10348, #10524

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:58:07 -03:00

97 lines
3.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import {
sseCommentsEnabled,
shapeForClientFormat,
createSseHeartbeatTransform,
HEARTBEAT_SHAPES,
} from "../../open-sse/utils/sseHeartbeat.ts";
function withEnv(value: string | undefined, fn: () => void) {
const prev = process.env.OMNIROUTE_SSE_COMMENTS;
try {
if (value === undefined) delete process.env.OMNIROUTE_SSE_COMMENTS;
else process.env.OMNIROUTE_SSE_COMMENTS = value;
fn();
} finally {
if (prev === undefined) delete process.env.OMNIROUTE_SSE_COMMENTS;
else process.env.OMNIROUTE_SSE_COMMENTS = prev;
}
}
async function withEnvAsync<T>(value: string | undefined, fn: () => Promise<T>): Promise<T> {
const prev = process.env.OMNIROUTE_SSE_COMMENTS;
try {
if (value === undefined) delete process.env.OMNIROUTE_SSE_COMMENTS;
else process.env.OMNIROUTE_SSE_COMMENTS = value;
return await fn();
} finally {
if (prev === undefined) delete process.env.OMNIROUTE_SSE_COMMENTS;
else process.env.OMNIROUTE_SSE_COMMENTS = prev;
}
}
async function collectHeartbeatOutput(
shape: (typeof HEARTBEAT_SHAPES)[keyof typeof HEARTBEAT_SHAPES]
) {
const enc = new TextEncoder();
let closeTimer: ReturnType<typeof setTimeout> | undefined;
const input = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(enc.encode("data: ordinary\n\n"));
closeTimer = setTimeout(() => controller.close(), 35);
},
cancel() {
if (closeTimer) clearTimeout(closeTimer);
},
});
return new Response(
input.pipeThrough(createSseHeartbeatTransform({ shape, intervalMs: 10 }))
).text();
}
test("sseCommentsEnabled defaults to false when the env var is unset (#10524)", () => {
withEnv(undefined, () => assert.equal(sseCommentsEnabled(), false));
});
test("sseCommentsEnabled is false for 'off', 'false', '0', 'no' (case-insensitive)", () => {
withEnv("off", () => assert.equal(sseCommentsEnabled(), false));
withEnv("OFF", () => assert.equal(sseCommentsEnabled(), false));
withEnv("false", () => assert.equal(sseCommentsEnabled(), false));
withEnv("FALSE", () => assert.equal(sseCommentsEnabled(), false));
withEnv("0", () => assert.equal(sseCommentsEnabled(), false));
withEnv("no", () => assert.equal(sseCommentsEnabled(), false));
withEnv("NO", () => assert.equal(sseCommentsEnabled(), false));
withEnv("on", () => assert.equal(sseCommentsEnabled(), true));
withEnv("yes", () => assert.equal(sseCommentsEnabled(), true));
withEnv("1", () => assert.equal(sseCommentsEnabled(), true));
});
test("shapeForClientFormat maps known client formats", () => {
assert.equal(shapeForClientFormat("claude"), HEARTBEAT_SHAPES.ANTHROPIC_PING);
assert.equal(shapeForClientFormat("openai"), HEARTBEAT_SHAPES.OPENAI_CHUNK);
assert.equal(
shapeForClientFormat("openai-responses"),
HEARTBEAT_SHAPES.OPENAI_RESPONSES_IN_PROGRESS
);
assert.equal(shapeForClientFormat(undefined), HEARTBEAT_SHAPES.COMMENT);
});
test("comment opt-out suppresses only COMMENT heartbeats, not data-event heartbeats", async () => {
await withEnvAsync("off", async () => {
const comment = await collectHeartbeatOutput(HEARTBEAT_SHAPES.COMMENT);
assert.doesNotMatch(comment, /: keepalive/, "comment heartbeat should be suppressed");
assert.match(comment, /data: ordinary/, "ordinary data should pass through unchanged");
const openAI = await collectHeartbeatOutput(HEARTBEAT_SHAPES.OPENAI_CHUNK);
assert.match(openAI, /"object":"chat\.completion\.chunk"/);
const anthropic = await collectHeartbeatOutput(HEARTBEAT_SHAPES.ANTHROPIC_PING);
assert.match(anthropic, /event: ping\ndata: \{"type":"ping"\}/);
const responses = await collectHeartbeatOutput(HEARTBEAT_SHAPES.OPENAI_RESPONSES_IN_PROGRESS);
assert.match(responses, /data: \{"type":"response\.in_progress"\}/);
});
});