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.
This commit is contained in:
@@ -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)}` : ""}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user