mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
fix(security): redact dashboard failure events
This commit is contained in:
1
changelog.d/fixes/dashboard-request-failed-redaction.md
Normal file
1
changelog.d/fixes/dashboard-request-failed-redaction.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(security):** sanitize `request.failed` diagnostics before publishing them to live dashboard listeners and replay history, while keeping status, model, provider, latency, and internal call-log diagnostics intact.
|
||||
@@ -17,6 +17,7 @@ import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { FORMATS } from "../../translator/formats.ts";
|
||||
import { takeEarlyKeepaliveBytes } from "../../utils/earlyKeepaliveByteBuffer.ts";
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts";
|
||||
import { attachLogMeta } from "./cacheUsageMeta.ts";
|
||||
|
||||
@@ -157,7 +158,9 @@ export function resolveRequestLifecycleEvent(input: {
|
||||
name: "request.failed",
|
||||
payload: {
|
||||
id: traceId,
|
||||
error: error || `HTTP ${status}`,
|
||||
// Dashboard listeners and event history cross a public WebSocket boundary. Keep the raw
|
||||
// diagnostic in the call log/pipeline above, but expose only the canonical safe projection.
|
||||
error: sanitizeErrorMessage(error || `HTTP ${status}`),
|
||||
statusCode: typeof status === "number" ? status : undefined,
|
||||
latencyMs,
|
||||
model: model || undefined,
|
||||
|
||||
125
tests/fixtures/dashboard-request-failed-redaction-probe.ts
vendored
Normal file
125
tests/fixtures/dashboard-request-failed-redaction-probe.ts
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { RequestFailedPayload } from "../../src/lib/events/types.ts";
|
||||
|
||||
const RESULT_PREFIX = "DASHBOARD_FAILURE_PROBE_RESULT=";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
assert.ok(process.env.DATA_DIR, "probe requires an isolated DATA_DIR");
|
||||
assert.ok(process.env.OMNIROUTE_PLUGINS_DIR, "probe requires an isolated plugins directory");
|
||||
assert.ok(process.env.API_KEY_SECRET, "probe requires a synthetic API_KEY_SECRET");
|
||||
|
||||
const { persistAttemptLogs } = await import("../../open-sse/handlers/chatCore/attemptLogging.ts");
|
||||
const eventBus = await import("../../src/lib/events/eventBus.ts");
|
||||
const dbCore = await import("../../src/lib/db/core.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
try {
|
||||
globalThis.__omnirouteEventBus = undefined;
|
||||
const hostileError = new Error(
|
||||
"Provider failed in /srv/omniroute/src/private/provider.ts:42:7 with " +
|
||||
"api_key='sk-live-dashboard-secret'"
|
||||
);
|
||||
hostileError.stack =
|
||||
`${hostileError.name}: ${hostileError.message}\n` +
|
||||
" at dispatch (/srv/omniroute/src/private/transport.ts:91:3)";
|
||||
const rawDiagnostic = hostileError.stack;
|
||||
const traceId = "trace-dashboard-redaction";
|
||||
const callLogId = "call-log-dashboard-redaction";
|
||||
|
||||
const deliveredPromise = new Promise<RequestFailedPayload>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe?.();
|
||||
reject(new Error("timed out waiting for persistAttemptLogs request.failed event"));
|
||||
}, 10_000);
|
||||
unsubscribe = eventBus.on("request.failed", (payload) => {
|
||||
if (payload.id !== traceId) return;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe?.();
|
||||
unsubscribe = undefined;
|
||||
resolve(payload);
|
||||
});
|
||||
});
|
||||
|
||||
persistAttemptLogs(
|
||||
{
|
||||
status: 502,
|
||||
tokens: {},
|
||||
responseBody: null,
|
||||
error: rawDiagnostic,
|
||||
},
|
||||
{
|
||||
traceId,
|
||||
provider: "private-provider",
|
||||
connectionId: null,
|
||||
model: "private-model",
|
||||
skillRequestId: "skill-dashboard-redaction",
|
||||
detailedLoggingEnabled: false,
|
||||
reqLogger: null,
|
||||
pendingRequestId: callLogId,
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions" },
|
||||
requestedModel: "private-model",
|
||||
credentials: null,
|
||||
startTime: Date.now() - 37,
|
||||
body: { model: "private-model", messages: [] },
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "openai",
|
||||
comboName: null,
|
||||
comboStepId: null,
|
||||
comboExecutionKey: null,
|
||||
tokensCompressed: null,
|
||||
apiKeyInfo: null,
|
||||
noLogEnabled: false,
|
||||
correlationId: null,
|
||||
modelPinned: false,
|
||||
sessionTag: null,
|
||||
}
|
||||
);
|
||||
|
||||
const delivered = await deliveredPromise;
|
||||
assert.equal(delivered.id, traceId);
|
||||
assert.equal(delivered.statusCode, 502);
|
||||
assert.equal(delivered.model, "private-model");
|
||||
assert.equal(delivered.provider, "private-provider");
|
||||
assert.ok(delivered.latencyMs >= 0);
|
||||
assert.equal(delivered.error, "Error: Provider failed in <path> with api_key='[REDACTED]'");
|
||||
assert.doesNotMatch(delivered.error, /sk-live-dashboard-secret|\/srv\/omniroute|\n/);
|
||||
|
||||
const replayed = eventBus
|
||||
.getEventHistory(undefined, 10)
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.event === "request.failed" &&
|
||||
(entry.payload as RequestFailedPayload | undefined)?.id === traceId
|
||||
);
|
||||
assert.ok(replayed, "late subscribers must have the safe request.failed history entry");
|
||||
assert.deepEqual(replayed.payload, delivered);
|
||||
|
||||
const writerDrained = await callLogs.waitForCallLogSaves(10_000);
|
||||
assert.equal(writerDrained, true, "call-log write must drain");
|
||||
const persisted = await callLogs.getCallLogById(callLogId);
|
||||
assert.ok(persisted, "failed attempt must still be available to internal diagnostics");
|
||||
assert.equal(persisted.error, rawDiagnostic);
|
||||
|
||||
console.log(
|
||||
RESULT_PREFIX +
|
||||
JSON.stringify({
|
||||
delivered,
|
||||
replayMatches: JSON.stringify(replayed.payload) === JSON.stringify(delivered),
|
||||
internalRawPreserved: persisted.error === rawDiagnostic,
|
||||
writerDrained,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
try {
|
||||
await callLogs.waitForCallLogSaves(10_000);
|
||||
await callLogs.closeCallLogSaves(10_000);
|
||||
} finally {
|
||||
dbCore.resetDbInstance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
129
tests/unit/dashboard-request-failed-redaction.test.ts
Normal file
129
tests/unit/dashboard-request-failed-redaction.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { RequestFailedPayload } from "../../src/lib/events/types.ts";
|
||||
|
||||
const RESULT_PREFIX = "DASHBOARD_FAILURE_PROBE_RESULT=";
|
||||
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const probePath = fileURLToPath(
|
||||
new URL("../fixtures/dashboard-request-failed-redaction-probe.ts", import.meta.url)
|
||||
);
|
||||
|
||||
type ProbeResult = {
|
||||
delivered: RequestFailedPayload;
|
||||
replayMatches: boolean;
|
||||
internalRawPreserved: boolean;
|
||||
writerDrained: boolean;
|
||||
};
|
||||
|
||||
function runProbe(env: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
["--import", "tsx/esm", probePath],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(
|
||||
new Error(
|
||||
`dashboard failure probe exited unsuccessfully: ${error.message}\n` +
|
||||
`stdout:\n${stdout}\nstderr:\n${stderr}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("persistAttemptLogs redacts request.failed delivery/replay but keeps its internal log", async () => {
|
||||
const isolationRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-dashboard-failure-redaction-")
|
||||
);
|
||||
const dataDir = path.join(isolationRoot, "data");
|
||||
const pluginsDir = path.join(isolationRoot, "plugins");
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// The subprocess receives only process/runtime basics plus synthetic OmniRoute settings: no
|
||||
// provider credentials are inherited and no parent singleton/env/global state is mutated.
|
||||
const { stdout, stderr } = await runProbe({
|
||||
PATH: process.env.PATH,
|
||||
NODE_PATH: process.env.NODE_PATH,
|
||||
LANG: process.env.LANG,
|
||||
LC_ALL: process.env.LC_ALL,
|
||||
TZ: process.env.TZ,
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
NODE_ENV: "test",
|
||||
DATA_DIR: dataDir,
|
||||
OMNIROUTE_PLUGINS_DIR: pluginsDir,
|
||||
API_KEY_SECRET: "test-dashboard-failure-redaction-secret",
|
||||
PII_RESPONSE_SANITIZATION: "false",
|
||||
OMNIROUTE_ENABLE_LIVE_WS: "0",
|
||||
});
|
||||
|
||||
assert.doesNotMatch(stderr, /sk-live-dashboard-secret|\/srv\/omniroute/);
|
||||
const resultLine = stdout.split(/\r?\n/).find((line) => line.startsWith(RESULT_PREFIX));
|
||||
assert.ok(resultLine, `probe did not emit its result marker; stdout:\n${stdout}`);
|
||||
const result = JSON.parse(resultLine.slice(RESULT_PREFIX.length)) as ProbeResult;
|
||||
|
||||
assert.equal(result.delivered.id, "trace-dashboard-redaction");
|
||||
assert.equal(result.delivered.statusCode, 502);
|
||||
assert.equal(result.delivered.model, "private-model");
|
||||
assert.equal(result.delivered.provider, "private-provider");
|
||||
assert.equal(
|
||||
result.delivered.error,
|
||||
"Error: Provider failed in <path> with api_key='[REDACTED]'"
|
||||
);
|
||||
assert.equal(result.replayMatches, true);
|
||||
assert.equal(result.internalRawPreserved, true);
|
||||
assert.equal(result.writerDrained, true);
|
||||
} finally {
|
||||
// The probe exits only after draining/closing its writer and resetting its DB singleton.
|
||||
fs.rmSync(isolationRoot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the private LiveWS bridge forwards the already-safe event into its backlog unchanged", () => {
|
||||
const source = fs.readFileSync(
|
||||
fileURLToPath(new URL("../../src/server/ws/liveServer.ts", import.meta.url)),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// publishDashboardEvent/eventHistoryBacklog are module-private. This bounded source-chain
|
||||
// assertion avoids opening a server while proving the bus payload is what live delivery and
|
||||
// welcome/backlog replay store. The behavioral safety assertion lives in the subprocess above.
|
||||
assert.match(
|
||||
source,
|
||||
/eventHistoryBacklog\.push\(\{ event, payload, timestamp \}\)/,
|
||||
"the LiveWS backlog must store the event-bus payload"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/data:\s*h\.payload/,
|
||||
"welcome replay must forward the stored backlog payload"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/onAny\(\(event:[^\n]+payload:[^\n]+\)\s*=>\s*\{\s*publishDashboardEvent\(event, payload\)/,
|
||||
"the LiveWS bridge must publish the same event-bus payload"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user