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:
@@ -1,5 +1,6 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
import type { AuthorizationDecision } from "./advanced.ts";
|
||||
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
|
||||
import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts";
|
||||
import type { AuthzScope } from "./types.ts";
|
||||
|
||||
@@ -11,6 +12,12 @@ export const AUTHZ_LOCALS_KEY = "_authz";
|
||||
|
||||
interface RequestAuthz {
|
||||
resolver: AuthzResolver;
|
||||
/**
|
||||
* Same sink `decide()` records through. Stashed here too so a denial that
|
||||
* never reaches the resolver (e.g. `guardPermission`'s `getResource`
|
||||
* throwing) can still be audited, instead of vanishing from the trail.
|
||||
*/
|
||||
audit: AuthzAuditSink | undefined;
|
||||
/** Memo for object resources, keyed by identity so two rows never collide. */
|
||||
byRef: WeakMap<object, Map<string, Promise<AuthorizationDecision>>>;
|
||||
/** Memo for symbol resources, keyed by identity for the same reason. */
|
||||
@@ -36,6 +43,7 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware {
|
||||
return (ctx, next) => {
|
||||
const request: RequestAuthz = {
|
||||
resolver,
|
||||
audit: options.audit,
|
||||
byRef: new WeakMap(),
|
||||
bySymbol: new Map(),
|
||||
byValue: new Map(),
|
||||
@@ -54,6 +62,16 @@ function currentScope(ctx: Context): AuthzScope | undefined {
|
||||
return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same normalization `decide()` applies before handing a subject id to the
|
||||
* audit sink: a non-empty string, or undefined (never a raw non-string id
|
||||
* leaking into an audit record).
|
||||
*/
|
||||
function subjectIdOf(ctx: Context): string | undefined {
|
||||
const rawId = (ctx.user as { id?: unknown } | null | undefined)?.id;
|
||||
return typeof rawId === "string" && rawId !== "" ? rawId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Object resources are memoised by identity (`byRef`), never by serialising
|
||||
* their contents — serialisation is what let unrelated resources collide
|
||||
@@ -203,6 +221,21 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
|
||||
resource = await options.getResource(ctx);
|
||||
} catch (error) {
|
||||
console.error(`[wrnexus:authz] getResource threw for '${permission}'; denying`, error);
|
||||
// This denial never reaches decideFor()/decide()/finish() — the
|
||||
// resource load failed before there was anything to decide — so
|
||||
// without recording here it would be invisible to the audit trail:
|
||||
// an attacker probing ids that make the loader throw gets a clean
|
||||
// 403 stream no operator can see. Keep the response body opaque
|
||||
// (no loader message), same as every other guardPermission denial.
|
||||
const { audit } = readAuthz(ctx);
|
||||
safeRecord(audit, {
|
||||
subjectId: subjectIdOf(ctx),
|
||||
scope: currentScope(ctx),
|
||||
permission,
|
||||
allowed: false,
|
||||
reason: "Resource unavailable",
|
||||
at: Date.now(),
|
||||
});
|
||||
return Response.json(
|
||||
{ ok: false, error: "Forbidden" },
|
||||
{ status: 403, headers: NO_STORE_HEADERS },
|
||||
|
||||
Reference in New Issue
Block a user