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>
Fix round 3 for Task 7 (N5, minor-to-important): fix round 2's
encodeURI(options.redirectTo) fixed the non-ASCII crash but broke the
most common real use of redirectTo -- a return-path query param that's
already percent-encoded (e.g. /login?next=%2Fdash) -- because encodeURI
also escapes "%", double-encoding it to %252Fdash. Replaced with
headerSafePath(), a codepoint loop that encodes only codepoints above
0x7f (matching isLocalPath's style: no regex, no source escapes) and
leaves "%" alone.
Added tests: an already-percent-encoded target round-trips unchanged;
a non-ASCII target still 303s without throwing and the location is
ASCII-only; a plain ASCII target passes through byte-identical.
Fix round 2 for Task 7 (plan amendment 9e3624e5):
- N1 (Important): the memo key carried scope and permission but not the
subject, so a request that reassigns ctx.user mid-flight (impersonation,
step-up auth, session revocation, or an authz-before-auth middleware
ordering mistake) could be served the previous principal's cached
verdict. subjectId (typeof + String, matching the existing scope/value
encoding style) is now folded into every memo key.
- N2 (Minor): the primitive-value memo key used String(resource), which
collapses distinct Symbol("row") values into one slot and maps -0 onto
0's slot. Added a dedicated bySymbol identity memo (WeakMap-style, but a
plain Map since symbols aren't valid WeakMap keys pre-registry symbols
and the memo is request-scoped anyway) and special-cased Object.is(x,-0)
to render as "-0".
- N3 (Minor): the rejected-redirect console.error interpolated
redirectTo directly, exactly the value most likely to carry CR/LF in
that branch. Switched to JSON.stringify(redirectTo) for the log line.
- N4 (Minor): a non-ASCII (but otherwise valid, local) redirectTo passed
isLocalPath and then threw inside `new Response` building the Location
header. Wrapped it in encodeURI().
Added 5 regression tests: subject swap re-evaluates, clearing ctx.user
denies, two same-description symbols get separate verdicts, 0 vs -0 get
separate verdicts, non-ASCII redirectTo 303s with an encoded location
instead of throwing. N1 revert-checked: temporarily restored the
two-element (no-subject) key and confirmed both subject-swap tests fail
against it before restoring the fix.
Fix round 1 for Task 7, addressing review findings against the brief's
own memoKey design (now superseded per plan amendment cc8085bc):
- C1: memoKey's String(id) + JSON.stringify-with-catch cross-authorized
distinct resources whenever their ids stringified the same (numeric
vs string ids, object-shaped ids) or whenever JSON.stringify threw
(circular references, BigInt fields, throwing getters all shared one
"<unserialisable>" bucket, so the first verdict computed for any of
them became the cached verdict for all of them in that request).
- C2: filterCan inherited the same bypass, returning rows the subject
could not act on.
- Replaced serialisation-based memoization with identity-based
memoization: object resources are memoised in a WeakMap keyed by the
resource reference itself (never serialised), primitives/absent
resources in a Map keyed by [scope, permission, typeof, String(value)]
so 7 and "7" can never collide.
- I1: scope is now read from ctx.tenant at decision time (currentScope),
not captured once at middleware-install time, so a tenant switch
mid-request is honoured on the next check.
- I2/M1: guardPermission's redirectTo now only fires for non-JSON/API
requests (replicated wantsJson check, since authz may only import
core as types) and only for a validated local path (isLocalPath),
closing an open-redirect and a JSON-caller-follows-303 gap.
- I3: getResource is now wrapped in try/catch; a throw denies with the
standard opaque 403 body instead of propagating the loader's error
(e.g. a SQL string) to the client.
- Added cache-control: private, no-store to both the 303 and 403
responses.
Added 11 regression tests. C1/C2 revert-checked: temporarily restored
the old memoKey design and confirmed the four collision tests fail
against it before restoring the fix.
Installs a per-request authz resolver via authzMiddleware and exposes
can()/decideFor()/guardPermission()/filterCan() as free functions (not
Context members, so @wrnexus/core stays free of an authz dependency).
All four route through resolver.decide(), never permissionsFor(), so
resource-scoped policy denials can't be bypassed via the coarse
permission set. Per-request results are memoised keyed on (permission,
resource) to avoid re-hitting the store within a request without
leaking one resource's verdict onto another.