Files
WRNexusJS/packages/authz/src/middleware.ts
T
ClintchizandClaude Opus 5 3867e7c183 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>
2026-08-05 02:10:11 +05:30

287 lines
11 KiB
TypeScript

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";
/**
* `can` is deliberately not a Context member: @wrnexus/core must not depend on
* @wrnexus/authz. The per-request resolver lives here instead.
*/
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. */
bySymbol: Map<symbol, Map<string, Promise<AuthorizationDecision>>>;
/** Memo for primitive and absent resources. */
byValue: Map<string, Promise<AuthorizationDecision>>;
}
function readAuthz(ctx: Context): RequestAuthz {
const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined;
if (!value) {
throw new Error(
"WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " +
"Add it to app/middleware before calling can()/guardPermission().",
);
}
return value;
}
/** Install the per-request resolver. Register early, after sessionAuth. */
export function authzMiddleware(options: AuthzResolverOptions): Middleware {
const resolver = createAuthzResolver(options);
return (ctx, next) => {
const request: RequestAuthz = {
resolver,
audit: options.audit,
byRef: new WeakMap(),
bySymbol: new Map(),
byValue: new Map(),
};
ctx.locals[AUTHZ_LOCALS_KEY] = request;
return next();
};
}
/**
* Read the tenant from the context at decision time, not at middleware time:
* a request that switches tenant mid-flight must not keep the old scope.
*/
function currentScope(ctx: Context): AuthzScope | undefined {
const tenantId = ctx.tenant?.id;
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
* (same `id` shape, circular references, BigInt fields, throwing getters all
* funnelled into one bucket). Symbols are memoised by identity too (`bySymbol`)
* since `String(symbol)` collapses distinct symbols with the same description.
* Primitive/absent resources are memoised by a
* `[scope, permission, typeof, String(value)]` tuple, with `-0` rendered
* distinctly from `0` since `String(-0) === "0"` would otherwise merge them.
*
* Subject and scope are both part of the key. A request that reassigns
* ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
* must not be served the previous principal's verdict from the memo.
*/
export function decideFor(
ctx: Context,
permission: string,
resource?: unknown,
): Promise<AuthorizationDecision> {
const request = readAuthz(ctx);
const scope = currentScope(ctx);
const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id;
const key = JSON.stringify([
scope?.tenantId ?? "",
permission,
typeof subjectId,
String(subjectId),
]);
const run = () =>
request.resolver.decide({
subject: ctx.user as { id?: string } | null | undefined,
permission,
resource,
scope,
});
// Symbols carry identity that String() erases, so they memo by identity too.
// They are held in a plain Map rather than the WeakMap: the memo is discarded
// with the request, so there is nothing to leak.
if (typeof resource === "symbol") {
let perSymbol = request.bySymbol.get(resource);
if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map()));
const cached = perSymbol.get(key);
if (cached) return cached;
const pending = run();
perSymbol.set(key, pending);
return pending;
}
const isObjectResource =
resource !== null &&
resource !== undefined &&
(typeof resource === "object" || typeof resource === "function");
if (isObjectResource) {
const resourceObject = resource as object;
let inner = request.byRef.get(resourceObject);
if (!inner) {
inner = new Map();
request.byRef.set(resourceObject, inner);
}
const cached = inner.get(key);
if (cached) return cached;
const pending = run();
inner.set(key, pending);
return pending;
}
const rendered = Object.is(resource, -0) ? "-0" : String(resource);
const valueKey = JSON.stringify([key, typeof resource, rendered]);
const cached = request.byValue.get(valueKey);
if (cached) return cached;
const pending = run();
request.byValue.set(valueKey, pending);
return pending;
}
export async function can(ctx: Context, permission: string, resource?: unknown): Promise<boolean> {
return (await decideFor(ctx, permission, resource)).allowed;
}
/**
* Replicates `packages/core/src/auth.ts`'s `wantsJson` (not imported: authz
* may only pull TYPES from @wrnexus/core, never runtime code).
*/
function wantsJson(ctx: Context): boolean {
if (ctx.url.pathname.startsWith("/api/")) return true;
const accept = ctx.req.headers.get("accept") ?? "";
return accept.includes("application/json") && !accept.includes("text/html");
}
/**
* Refuse anything but a same-origin, same-app path: no scheme/host
* (`https://evil.example.com/...`), no protocol-relative target (`//evil...`
* is host-relative in a browser, not path-relative), no backslashes (some
* user agents treat `\` as `/`, which can smuggle a host past a naive
* `startsWith("/")` check), and no control characters (CR/LF header/response
* splitting, etc). Written as a codepoint loop rather than a control-char
* regex literal, which tooling in this repo mangles.
*/
function isLocalPath(target: string): boolean {
if (!target.startsWith("/")) return false;
if (target.startsWith("//")) return false;
if (target.includes("\\")) return false;
for (const ch of target) {
const code = ch.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return false;
}
return true;
}
/**
* Header values must be Latin-1, so a localized path would otherwise throw
* inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
* that cannot be sent: encodeURI would also escape "%", corrupting a target
* that already carries a percent-encoded return path.
*/
function headerSafePath(value: string): string {
let out = "";
for (const character of value) {
out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
}
return out;
}
const NO_STORE_HEADERS = { "cache-control": "private, no-store" } as const;
export interface GuardOptions {
/** Load the resource a bound policy needs. */
getResource?: (ctx: Context) => unknown;
/** Include reason and policy name in the 403 body. Off by default. */
exposeReason?: boolean;
/** Redirect page requests here instead of returning 403. Ignored for JSON/API requests and for any non-local target. */
redirectTo?: string;
}
/**
* Guard a route on a registered permission. Named `guardPermission` because
* `requirePermission(rbac, permission)` already exists with a different shape.
*/
export function guardPermission(permission: string, options: GuardOptions = {}): Middleware {
return async (ctx, next) => {
let resource: unknown;
if (options.getResource) {
try {
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 },
);
}
}
const result = await decideFor(ctx, permission, resource);
if (result.allowed) return next();
if (options.redirectTo && !wantsJson(ctx)) {
if (isLocalPath(options.redirectTo)) {
return new Response(null, {
status: 303,
// headerSafePath, not encodeURI: a non-ASCII local path (e.g. a
// localized login route) is valid config but not a valid raw
// header value, while encodeURI would also mangle a target that
// already carries a percent-encoded return path.
headers: { location: headerSafePath(options.redirectTo), ...NO_STORE_HEADERS },
});
}
// JSON.stringify, not string interpolation: this branch exists precisely
// for targets containing CR/LF, which must not reach the log verbatim.
console.error(
`[wrnexus:authz] guardPermission redirectTo ${JSON.stringify(options.redirectTo)} is not a local path; falling back to 403`,
);
}
return Response.json(
options.exposeReason
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
};
}
/** Keep only the items the current subject may act on. */
export async function filterCan<T>(
ctx: Context,
permission: string,
items: readonly T[],
): Promise<T[]> {
const verdicts = await Promise.all(
items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })),
);
return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item);
}