mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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');
|
||||
});
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user