From e710756baf0a08f39ce252d446c553a320c24d6d Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:21:09 +0530 Subject: [PATCH] fix(authz): sanitize control characters in console audit sink Prevents audit log injection: subjectId, tenantId, and reason trace back to request input, so an unsanitized newline could forge a second, fully-formed audit line indistinguishable from a real entry. Adds logSafe() to strip control characters before interpolation and logs the previously-missing policy field. --- packages/authz/src/audit.ts | 22 +++++++++++++--- packages/authz/test/audit.test.ts | 44 ++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/authz/src/audit.ts b/packages/authz/src/audit.ts index d8f6a091..ecfb0b34 100644 --- a/packages/authz/src/audit.ts +++ b/packages/authz/src/audit.ts @@ -29,14 +29,30 @@ export function memoryAuditSink(): MemoryAuditSink { }; } +/** + * Subject ids, tenant ids, and denial reasons trace back to request input, so + * a newline in one would forge a second audit line indistinguishable from a + * real entry. Strip control characters before interpolating. + */ +function logSafe(value: string): string { + let out = ""; + for (const character of value) { + const code = character.codePointAt(0)!; + out += code < 0x20 || code === 0x7f ? " " : character; + } + return out; +} + export function consoleAuditSink(): AuthzAuditSink { return { record(event) { const verdict = event.allowed ? "allow" : "deny"; console.info( - `[wrnexus:authz] ${verdict} ${event.permission} subject=${event.subjectId ?? "anonymous"}` + - `${event.scope?.tenantId ? ` tenant=${event.scope.tenantId}` : ""}` + - `${event.reason ? ` reason=${event.reason}` : ""}`, + `[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` + + `subject=${logSafe(event.subjectId ?? "anonymous")}` + + `${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` + + `${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` + + `${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`, ); }, }; diff --git a/packages/authz/test/audit.test.ts b/packages/authz/test/audit.test.ts index 12b8c7d3..833c9651 100644 --- a/packages/authz/test/audit.test.ts +++ b/packages/authz/test/audit.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { memoryAuditSink, safeRecord } from "../src/audit.ts"; +import { + consoleAuditSink, + memoryAuditSink, + safeRecord, + type AuthzAuditSink, +} from "../src/audit.ts"; describe("audit sink", () => { test("memoryAuditSink collects events", () => { @@ -28,4 +33,41 @@ describe("audit sink", () => { test("safeRecord tolerates an undefined sink", () => { expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow(); }); + + test("safeRecord tolerates a malformed sink", () => { + const notAFunction = { record: "nope" } as unknown as AuthzAuditSink; + expect(() => + safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }), + ).not.toThrow(); + expect(() => + safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }), + ).not.toThrow(); + }); + + test("memoryAuditSink.clear empties the buffer", () => { + const sink = memoryAuditSink(); + sink.record({ permission: "p:x", allowed: true, at: 1 }); + sink.clear(); + expect(sink.events).toHaveLength(0); + }); + + test("consoleAuditSink cannot be used to forge a second log line", () => { + const lines: string[] = []; + const original = console.info; + console.info = (...args: unknown[]) => void lines.push(args.join(" ")); + try { + consoleAuditSink().record({ + subjectId: "u1\n[wrnexus:authz] allow admin:everything subject=root", + permission: "post:read", + allowed: false, + reason: "nope\r\ninjected", + at: 1, + }); + } finally { + console.info = original; + } + expect(lines).toHaveLength(1); + expect(lines[0]).not.toContain("\n"); + expect(lines[0]).not.toContain("\r"); + }); });