mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
fix(hub-only-guard): reject hub-only collection paths without trailing slash (#1142)
HUB_ONLY_PREFIXES entries are stored with a trailing slash, but isHubOnlyPath() used a bare startsWith(). The collection paths /api/scheduled-tasks, /api/audit-log, and /api/notification-routes (no trailing slash) silently fell through the guard and could be forwarded by the remote proxy, bypassing the hub-only boundary on three path families. The matcher now accepts the exact prefix (slash stripped) in addition to the slash-suffixed prefix. Adds three regression tests covering the bare-collection-path 403 for each hub-only family. Confirmed the new tests fail without the matcher change and pass with it.
This commit is contained in:
@@ -87,6 +87,39 @@ describe('hubOnlyGuard', () => {
|
|||||||
expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT');
|
expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: HUB_ONLY_PREFIXES entries carry a trailing slash, and a bare
|
||||||
|
// startsWith() match would let the collection paths (no trailing slash) fall
|
||||||
|
// through to the remote proxy. The matcher must accept both forms.
|
||||||
|
it('rejects /api/scheduled-tasks (no trailing slash) with 403 when nodeId targets a remote node', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/scheduled-tasks')
|
||||||
|
.set('Authorization', authHeader)
|
||||||
|
.set('x-node-id', String(remoteNodeId));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects /api/audit-log (no trailing slash) with 403 when nodeId targets a remote node', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/audit-log')
|
||||||
|
.set('Authorization', authHeader)
|
||||||
|
.set('x-node-id', String(remoteNodeId));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects /api/notification-routes (no trailing slash) with 403 when nodeId targets a remote node', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/notification-routes')
|
||||||
|
.set('Authorization', authHeader)
|
||||||
|
.set('x-node-id', String(remoteNodeId));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not interfere with non-hub paths even when nodeId targets a remote node', async () => {
|
it('does not interfere with non-hub paths even when nodeId targets a remote node', async () => {
|
||||||
// /api/stacks is not hub-only and should be forwarded by the proxy.
|
// /api/stacks is not hub-only and should be forwarded by the proxy.
|
||||||
// The exact upstream-error status is not the contract here; what
|
// The exact upstream-error status is not the contract here; what
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export function isProxyExemptPath(path: string): boolean {
|
|||||||
// nodeId resolves to a remote node so a script/curl call cannot trick the
|
// nodeId resolves to a remote node so a script/curl call cannot trick the
|
||||||
// proxy into forwarding hub-only authority across a node boundary.
|
// proxy into forwarding hub-only authority across a node boundary.
|
||||||
//
|
//
|
||||||
|
// Entries are stored with a trailing slash; the matcher accepts the exact
|
||||||
|
// collection path (without the trailing slash) AND any sub-path under it,
|
||||||
|
// so e.g. `/api/scheduled-tasks` and `/api/scheduled-tasks/42` both match.
|
||||||
|
// A bare startsWith() would let the collection path silently fall through
|
||||||
|
// and be forwarded by the remote proxy.
|
||||||
|
//
|
||||||
// Frontend nav surfaces are gated separately via `HUB_ONLY_VIEWS` in
|
// Frontend nav surfaces are gated separately via `HUB_ONLY_VIEWS` in
|
||||||
// useViewNavigationState.ts; this list is the backend defense-in-depth.
|
// useViewNavigationState.ts; this list is the backend defense-in-depth.
|
||||||
//
|
//
|
||||||
@@ -44,6 +50,7 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [
|
|||||||
export function isHubOnlyPath(path: string): boolean {
|
export function isHubOnlyPath(path: string): boolean {
|
||||||
for (const prefix of HUB_ONLY_PREFIXES) {
|
for (const prefix of HUB_ONLY_PREFIXES) {
|
||||||
if (path.startsWith(prefix)) return true;
|
if (path.startsWith(prefix)) return true;
|
||||||
|
if (path === prefix.slice(0, -1)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user