nucleus-core-ts 0.9.721 → 0.9.723

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,17 +38,33 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
38
38
  for(let i = 0; i < t.length; i++){
39
39
  const seg = t[i];
40
40
  if (seg === undefined) return false;
41
- if (seg.startsWith('{') && seg.endsWith('}')) continue;
41
+ if (seg.startsWith('{') && seg.endsWith('}')) {
42
+ // A path parameter must bind a REAL, non-empty segment. An empty segment (from a
43
+ // `//` double slash) is never a valid binding — reject it so the route stays
44
+ // unmapped (fail-closed) instead of over-matching the parameterized route's claim.
45
+ if (c[i] === '' || c[i] === undefined) return false;
46
+ continue;
47
+ }
42
48
  if (seg !== c[i]) return false;
43
49
  }
44
50
  return true;
45
51
  }
52
+ /** Count full-segment `{param}` wildcards — fewer = more specific (more literal segments). */ function templateSpecificity(template) {
53
+ return template.replace(/\/+$/, '').split('/').filter((s)=>s.startsWith('{') && s.endsWith('}')).length;
54
+ }
46
55
  export function findRequiredAction(manifest, method, path) {
47
56
  const m = method.toUpperCase();
48
- for (const r of manifest){
49
- if (r.method === m && pathMatchesTemplate(r.path, path)) return r.action;
50
- }
51
- return null;
57
+ const matches = manifest.filter((r)=>r.method === m && pathMatchesTemplate(r.path, path));
58
+ if (matches.length === 0) return null;
59
+ // MOST-SPECIFIC wins (never first-match): a literal route (`/files/admin`) must beat a
60
+ // parameterized sibling (`/files/{id}`) regardless of manifest order — else a request to
61
+ // an admin endpoint resolves to the weaker by-id claim (privilege escalation). Rank by
62
+ // fewest `{param}` segments, deterministic tiebreak on path for equal specificity.
63
+ matches.sort((a, b)=>{
64
+ const d = templateSpecificity(a.path) - templateSpecificity(b.path);
65
+ return d !== 0 ? d : a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
66
+ });
67
+ return matches[0]?.action ?? null;
52
68
  }
53
69
  function matchesGlob(path, patterns) {
54
70
  if (!patterns?.length) return false;
@@ -94,6 +94,54 @@ describe('findRequiredAction', ()=>{
94
94
  expect(findRequiredAction(MANIFEST, 'POST', '/api/v1/templates')).toBeNull();
95
95
  });
96
96
  });
97
+ describe('findRequiredAction — most-specific wins (round-4 privilege-escalation guards)', ()=>{
98
+ // Bug D: a LITERAL route must beat a parameterized sibling regardless of manifest
99
+ // declaration order — else GET /files/admin resolves to the weaker /files/{id} claim
100
+ // and a user holding only files.get reaches the admin endpoint.
101
+ const order = (o)=>{
102
+ const lit = {
103
+ action: 'files.admin',
104
+ path: '/files/admin',
105
+ method: 'GET'
106
+ };
107
+ const param = {
108
+ action: 'files.get',
109
+ path: '/files/{id}',
110
+ method: 'GET'
111
+ };
112
+ return o === 'literal-first' ? [
113
+ lit,
114
+ param
115
+ ] : [
116
+ param,
117
+ lit
118
+ ];
119
+ };
120
+ it('literal route beats parameterized sibling, in EITHER declaration order', ()=>{
121
+ expect(findRequiredAction(order('param-first'), 'GET', '/files/admin')).toBe('files.admin');
122
+ expect(findRequiredAction(order('literal-first'), 'GET', '/files/admin')).toBe('files.admin');
123
+ // a real id still resolves to the by-id claim
124
+ expect(findRequiredAction(order('param-first'), 'GET', '/files/42')).toBe('files.get');
125
+ });
126
+ // Bug E: a {param} must not bind an EMPTY segment produced by a `//` double slash;
127
+ // the route stays unmapped (fail-closed) rather than passing the {id}-route's claim.
128
+ it('a parameter never binds an empty segment (double slash) → unmapped', ()=>{
129
+ const manifest = [
130
+ {
131
+ action: 'x.gety',
132
+ path: '/x/{id}/y',
133
+ method: 'GET'
134
+ },
135
+ {
136
+ action: 'x.getxy',
137
+ path: '/x/y',
138
+ method: 'GET'
139
+ }
140
+ ];
141
+ expect(findRequiredAction(manifest, 'GET', '/x//y')).toBeNull();
142
+ expect(findRequiredAction(manifest, 'GET', '/x/5/y')).toBe('x.gety');
143
+ });
144
+ });
97
145
  describe('evaluateAccess', ()=>{
98
146
  const base = {
99
147
  manifest: MANIFEST,
@@ -41,7 +41,10 @@ export declare function pathMatchesTemplate(template: string, concrete: string):
41
41
  /**
42
42
  * Given the claim specs for a service and an incoming (method, concrete-path),
43
43
  * return the required claim action, or null when no route matches (the caller
44
- * decides allow/deny for unmapped routes).
44
+ * decides allow/deny for unmapped routes). When several templates match, the
45
+ * MOST-SPECIFIC one wins (never first-declared): a literal route must beat a
46
+ * parameterized sibling so `/files/admin` requires its own claim, not `/files/{id}`'s.
47
+ * This MUST stay in lockstep with the proxy's findRequiredAction (Client/Proxy/authz.ts).
45
48
  */
46
49
  export declare function matchRouteToAction(specs: ClaimSpec[], method: string, concretePath: string): string | null;
47
50
  /** An existing claim row as seen during reconcile. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.721",
3
+ "version": "0.9.723",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",