fix(authz): audit getResource denials; fail closed on a malformed denies shape
guardPermission's getResource catch returned 403 directly, never reaching decideFor -> decide -> finish, so the audit sink never saw it — an attacker probing ids that make the resource loader throw got a clean 403 stream invisible to the audit trail. The audit sink is now stashed on the per-request RequestAuthz object (authzMiddleware already receives it via AuthzResolverOptions), and the catch records an "allowed: false" event with an opaque reason before returning the 403. Also: the explicit-deny check sat outside decide()'s try/catch, and deniedBy() guarded on denies.length rather than Array.isArray(denies). A store returning denies as a bare string let new Set(denies) iterate characters instead of the permission, so the deny matched nothing and was silently discarded; a store omitting denies entirely threw straight out of decide(). Both are now validated and handled inside the try, denying via the same "Authorization store unavailable" path as any other store failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -312,6 +312,50 @@ describe("createAuthzResolver fail-closed regressions", () => {
|
||||
expect(decision.reason).toMatch(/explicit deny/i);
|
||||
});
|
||||
|
||||
test("a store returning a non-array `denies` (e.g. a string) denies rather than silently allowing", async () => {
|
||||
// new Set("post:write") would iterate CHARACTERS, not the permission, so
|
||||
// a store returning a malformed `denies` shape must not let an otherwise
|
||||
// role-granted permission slip through as allowed. Uses "post:comment:delete"
|
||||
// (granted via the "moderator" role's "post:comment:*" wildcard) rather
|
||||
// than "post:write", specifically because "post:write" is bound to the
|
||||
// "ownsPost" policy in this test catalog — a resource-ownership check
|
||||
// that would itself deny an unowned resource and mask the exact bug this
|
||||
// test exists to catch, passing for the wrong reason even without the fix.
|
||||
const store = memoryPermissionStore();
|
||||
await store.assignRole("u1", "moderator"); // moderator -> post:comment:* wildcard grant
|
||||
const malformed = {
|
||||
...store,
|
||||
assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => {
|
||||
const real = await store.assignmentsFor(subjectId, scope);
|
||||
return { ...real, denies: "post:comment:delete" as unknown as string[] };
|
||||
},
|
||||
};
|
||||
const resolver = createAuthzResolver({ catalog, store: malformed, strict: false });
|
||||
const result = await resolver.decide({
|
||||
subject: { id: "u1" },
|
||||
permission: "post:comment:delete",
|
||||
});
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("a store omitting `denies` entirely denies rather than throwing out of decide()", async () => {
|
||||
const store = memoryPermissionStore();
|
||||
await store.assignRole("u1", "editor");
|
||||
const malformed = {
|
||||
...store,
|
||||
assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => {
|
||||
const real = await store.assignmentsFor(subjectId, scope);
|
||||
const { denies: _denies, ...withoutDenies } = real;
|
||||
return withoutDenies as unknown as typeof real;
|
||||
},
|
||||
};
|
||||
const resolver = createAuthzResolver({ catalog, store: malformed, strict: false });
|
||||
// If decide() still threw/rejected instead of denying, this `await` would
|
||||
// reject and fail the test right here rather than reaching the assertion.
|
||||
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:write" });
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("non-string subject ids deny rather than falling back to anonymous", async () => {
|
||||
const { resolver } = make();
|
||||
const invalidIds: unknown[] = [0, "", 123, {}];
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Context } from "@wrnexus/core";
|
||||
import { defineAuthz } from "../src/registry.ts";
|
||||
import { mergeCatalogs } from "../src/catalog.ts";
|
||||
import { memoryPermissionStore } from "../src/store.ts";
|
||||
import { memoryAuditSink } from "../src/audit.ts";
|
||||
import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts";
|
||||
|
||||
const catalog = mergeCatalogs([
|
||||
@@ -307,6 +308,33 @@ describe("guardPermission hardening", () => {
|
||||
expect(body).toEqual({ ok: false, error: "Forbidden" });
|
||||
});
|
||||
|
||||
test("a throwing getResource still records exactly one audit event, not a silent gap", async () => {
|
||||
// The catch used to return the 403 directly, never entering
|
||||
// decideFor -> decide -> finish, so the audit sink never saw it — an
|
||||
// attacker probing ids that make the loader throw got a clean 403 stream
|
||||
// invisible to the audit trail.
|
||||
const ctx = makeCtx({ id: "u1" });
|
||||
const audit = memoryAuditSink();
|
||||
await authzMiddleware({ catalog, store: memoryPermissionStore(), strict: false, audit })(
|
||||
ctx,
|
||||
async () => new Response("ok"),
|
||||
);
|
||||
const guard = guardPermission("post:delete", {
|
||||
getResource: () => {
|
||||
throw new Error("SELECT * FROM posts WHERE id = 1 -- boom");
|
||||
},
|
||||
});
|
||||
const res = await guard(ctx, async () => new Response("passed"));
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).toEqual({ ok: false, error: "Forbidden" });
|
||||
expect(audit.events).toHaveLength(1);
|
||||
expect(audit.events[0]!.allowed).toBe(false);
|
||||
expect(audit.events[0]!.permission).toBe("post:delete");
|
||||
// The loader's message must never reach the audit record either.
|
||||
expect(JSON.stringify(audit.events[0])).not.toContain("SELECT");
|
||||
});
|
||||
|
||||
test("redirectTo issues a 303 for a page request", async () => {
|
||||
const ctx = makeCtx({ id: "u1" });
|
||||
await withMiddleware(ctx);
|
||||
|
||||
Reference in New Issue
Block a user