mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
8d1304b0df
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.
135 lines
5.0 KiB
TypeScript
135 lines
5.0 KiB
TypeScript
/**
|
|
* Regression guard for `hubOnlyGuard` middleware.
|
|
*
|
|
* Hub-only paths (e.g. /api/scheduled-tasks, /api/audit-log,
|
|
* /api/notification-routes) manage state owned by the local hub. When a
|
|
* request carries `x-node-id` for a remote node, the guard must reject
|
|
* with 409 before the remote proxy forwards it. Without this guard, a
|
|
* scripted client could trick the proxy into running hub-level operations
|
|
* on a remote instance, crossing a node-authority boundary that the UI
|
|
* promises will not happen.
|
|
*
|
|
* The guard sits between `enforceApiTokenScope` (step 13) and
|
|
* `createRemoteProxyMiddleware` (step 15).
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
|
|
describe('hubOnlyGuard', () => {
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authHeader: string;
|
|
let remoteNodeId: number;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
|
|
const { DatabaseService } = await import('../services/DatabaseService');
|
|
remoteNodeId = DatabaseService.getInstance().addNode({
|
|
name: 'hub-only-remote',
|
|
type: 'remote',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: 'http://127.0.0.1:1',
|
|
api_token: 'hub-only-token',
|
|
});
|
|
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
authHeader = `Bearer ${token}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
it('rejects /api/scheduled-tasks 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');
|
|
expect(res.body?.error).toMatch(/hub-only/i);
|
|
});
|
|
|
|
it('rejects /api/audit-log 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 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('lets /api/scheduled-tasks through to the local handler when no nodeId is set', async () => {
|
|
const res = await request(app)
|
|
.get('/api/scheduled-tasks/')
|
|
.set('Authorization', authHeader);
|
|
|
|
// Anything other than 403 with HUB_ONLY_ENDPOINT proves the guard fell
|
|
// through. Local handler may return 200 or a tier-gate 403; both are
|
|
// acceptable here, so assert on the body code rather than the status.
|
|
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
|
|
// matters is that the guard did not reject with HUB_ONLY_ENDPOINT.
|
|
const res = await request(app)
|
|
.get('/api/stacks')
|
|
.set('Authorization', authHeader)
|
|
.set('x-node-id', String(remoteNodeId));
|
|
|
|
expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT');
|
|
});
|
|
});
|