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('}'))
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
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",
|