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.