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:
Anso
2026-05-21 16:42:18 -04:00
committed by GitHub
parent 2563e30424
commit 8d1304b0df
2 changed files with 40 additions and 0 deletions
@@ -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
+7
View File
@@ -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;
}