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