diff --git a/backend/src/__tests__/hub-only-guard.test.ts b/backend/src/__tests__/hub-only-guard.test.ts index caee6e5c..635f2a4e 100644 --- a/backend/src/__tests__/hub-only-guard.test.ts +++ b/backend/src/__tests__/hub-only-guard.test.ts @@ -87,6 +87,39 @@ describe('hubOnlyGuard', () => { 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 () => { // /api/stacks is not hub-only and should be forwarded by the proxy. // The exact upstream-error status is not the contract here; what diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts index 4e7db70c..0ff3057e 100644 --- a/backend/src/helpers/proxyExemptPaths.ts +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -29,6 +29,12 @@ export function isProxyExemptPath(path: string): boolean { // nodeId resolves to a remote node so a script/curl call cannot trick the // 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 // 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 { for (const prefix of HUB_ONLY_PREFIXES) { if (path.startsWith(prefix)) return true; + if (path === prefix.slice(0, -1)) return true; } return false; }