@@ -27,6 +27,128 @@ describe('auth-gate (ADR-0069 session gate)', () => {
2727 expect ( isAuthGateAllowlisted ( '/api/v1/auth/sign-out/?x=1' ) ) . toBe ( true ) ;
2828 expect ( isAuthGateAllowlisted ( '/api/v1/data/x/' ) ) . toBe ( false ) ;
2929 } ) ;
30+
31+ // ── [#16839] The allow-list is ANCHORED ────────────────────────────────
32+ //
33+ // It used to match unanchored: `path.includes('/auth/')` at ANY position
34+ // and an `endsWith` suffix test at ANY depth. So a segment whose VALUE
35+ // spelled an allow-listed token carried the exemption, and object names
36+ // and record ids are TENANT-CONTROLLED. Both seams hand this predicate a
37+ // data-plane path directly — `HttpDispatcher.enforceAuthGate(context,
38+ // cleanPath)` and `RestServer.enforceAuth` (`req.path`) — so a tenant that
39+ // declared an object named `auth`, or held a record whose id is `health`,
40+ // handed a password-expired / MFA-required session a bypass on that
41+ // object's data routes.
42+ //
43+ // ⛔ These pin the DECISION for the exact paths the card measured, not the
44+ // spelling of the predicate, so they survive a rewrite of it.
45+ describe ( '[#16839] a tenant-controlled segment cannot buy the exemption' , ( ) => {
46+ it ( 'gates the four paths that were falsely exempt' , ( ) => {
47+ for ( const p of [
48+ '/data/auth/123' , // an object named `auth`
49+ '/meta/auth/objects' , // an object named `auth`
50+ '/data/x/health' , // a record whose id is `health`
51+ '/data/xyz/me/apps' ,
52+ ] ) {
53+ expect ( isAuthGateAllowlisted ( p ) , p ) . toBe ( false ) ;
54+ }
55+ } ) ;
56+
57+ it ( 'gates the same shapes under the REST mount, where the seam sees the base' , ( ) => {
58+ for ( const p of [
59+ '/api/v1/data/auth/123' ,
60+ '/api/v1/meta/auth/objects' ,
61+ '/api/v1/data/health' , // `/data/:object` with object = `health`
62+ '/api/v1/data/x/health' , // `/data/:object/:id` with id = `health`
63+ '/api/v1/data/contacts/me/apps' ,
64+ '/api/v1/data/environments/x/health' , // a scope-shaped OBJECT name, mid-path
65+ ] ) {
66+ expect ( isAuthGateAllowlisted ( p ) , p ) . toBe ( false ) ;
67+ }
68+ } ) ;
69+
70+ // ⭐ The card's own two control rows. Without them the block above would
71+ // read the same for a predicate that had simply started refusing
72+ // everything.
73+ it ( 'CONTROL — the genuinely-exempt path stays exempt and the protected one stays gated' , ( ) => {
74+ expect ( isAuthGateAllowlisted ( '/auth/me' ) ) . toBe ( true ) ;
75+ expect ( isAuthGateAllowlisted ( '/data/contacts/1' ) ) . toBe ( false ) ;
76+ } ) ;
77+
78+ // The other direction, at full width: every mount shape a real
79+ // remediation / bootstrap route arrives in must still be exempt. An
80+ // anchoring that is too strict fails HERE rather than in production.
81+ it ( 'keeps every genuinely-exempt route shape exempt' , ( ) => {
82+ for ( const p of [
83+ // dispatcher shape — the hono adapter strips the app prefix
84+ '/auth/sign-out' , '/auth/two-factor/enable' , '/auth/me/localization' ,
85+ '/auth' , '/health' , '/ready' , '/discovery' ,
86+ // REST + better-auth mounts
87+ '/api/auth' , '/api/auth/sign-in' ,
88+ '/api/v1/auth' , '/api/v1/auth/change-password' , '/api/v1/auth/me/permissions' ,
89+ '/api/v1/health' , '/api/v1/ready' , '/api/v1/discovery' ,
90+ '/api/v1/me/apps' , '/api/v1/me/localization' ,
91+ // environment-scoped mount — the dispatcher evaluates the gate
92+ // BEFORE its scoped-URL strip, so this spelling reaches the predicate
93+ '/api/v1/environments/env_1/auth/sign-out' ,
94+ '/environments/env_1/auth/sign-out' ,
95+ '/api/v1/environments/env_1/discovery' ,
96+ // the legacy `projects` spelling of the same scope (ADR-0006)
97+ '/api/v1/projects/env_1/auth/sign-out' ,
98+ ] ) {
99+ expect ( isAuthGateAllowlisted ( p ) , p ) . toBe ( true ) ;
100+ }
101+ } ) ;
102+
103+ // ⭐ CLAUSE ② DISCHARGE — the repair only ever REMOVES exemptions.
104+ //
105+ // The dispatch declared "nothing is newly accepted"; this measures it
106+ // instead of asserting it. `preAnchoringAllowlisted` is the predicate
107+ // this file's subject replaced, transcribed verbatim from `origin/main`
108+ // cf6e0a193b, and the corpus is every path of up to four segments drawn
109+ // from the vocabulary the two spellings can disagree on. A single
110+ // `new && !old` row means a path became NEWLY exempt, which is a
111+ // widening and is not this card's to make.
112+ it ( 'is a strict SUBSET of the pre-anchoring allow-list — nothing becomes newly exempt' , ( ) => {
113+ const OLD_PREFIXES = [ '/api/v1/auth/' , '/api/auth/' , '/auth/' ] ;
114+ const OLD_SUFFIXES = [ '/health' , '/ready' , '/discovery' , '/me/apps' , '/me/localization' ] ;
115+ const preAnchoringAllowlisted = ( rawPath : string | undefined | null ) : boolean => {
116+ if ( ! rawPath ) return true ;
117+ let path = rawPath . split ( '?' ) [ 0 ] || '/' ;
118+ let end = path . length ;
119+ while ( end > 1 && path . charCodeAt ( end - 1 ) === 47 ) end -- ;
120+ path = path . slice ( 0 , end ) || '/' ;
121+ if ( path . includes ( '/auth/' ) ) return true ;
122+ for ( const p of OLD_PREFIXES ) if ( path . startsWith ( p ) || path === p . replace ( / \/ $ / , '' ) ) return true ;
123+ for ( const s of OLD_SUFFIXES ) if ( path . endsWith ( s ) ) return true ;
124+ return false ;
125+ } ;
126+
127+ const SEG = [ 'api' , 'v1' , 'auth' , 'health' , 'ready' , 'discovery' , 'me' , 'apps' ,
128+ 'localization' , 'data' , 'meta' , 'ui' , 'environments' , 'projects' , 'env1' , 'x' ] ;
129+ const corpus : string [ ] = [ '/' , '' ] ;
130+ for ( const a of SEG ) {
131+ corpus . push ( `/${ a } ` ) ;
132+ for ( const b of SEG ) {
133+ corpus . push ( `/${ a } /${ b } ` ) ;
134+ for ( const c of SEG ) {
135+ corpus . push ( `/${ a } /${ b } /${ c } ` ) ;
136+ for ( const d of SEG ) corpus . push ( `/${ a } /${ b } /${ c } /${ d } ` ) ;
137+ }
138+ }
139+ }
140+
141+ const widened = corpus . filter ( ( p ) => isAuthGateAllowlisted ( p ) && ! preAnchoringAllowlisted ( p ) ) ;
142+ expect ( widened ) . toEqual ( [ ] ) ;
143+ // Anti-vacuity: the corpus really does exercise both predicates, and
144+ // the repair really did remove exemptions — a corpus that narrowed
145+ // nothing would satisfy the line above without measuring anything.
146+ const narrowed = corpus . filter ( ( p ) => ! isAuthGateAllowlisted ( p ) && preAnchoringAllowlisted ( p ) ) ;
147+ expect ( corpus . length ) . toBeGreaterThan ( 10_000 ) ;
148+ expect ( narrowed . length ) . toBeGreaterThan ( 0 ) ;
149+ expect ( narrowed ) . toContain ( '/data/x/health' ) ;
150+ } ) ;
151+ } ) ;
30152 } ) ;
31153
32154 describe ( 'evaluateAuthGate' , ( ) => {
0 commit comments