Files
sencho/backend/src/__tests__/proxy-sso-config-authz.test.ts
T
Anso c6d9fb98e5 fix(sso): enforce hub-only SSO config when remote node is active (#1865)
SSO configuration is control-plane state and must not follow the active
remote node. Add /api/sso/ to hub-only prefixes with case-insensitive
matching, hide the Settings section on remotes, and use localOnly on
every SSOSection fetch as defense in depth.
2026-08-29 20:10:26 -04:00

152 lines
5.4 KiB
TypeScript

/**
* Hub → remote proxy coverage for SSO configuration rejection.
* API tokens are blocked by the proxy-side rejectApiTokenScope gate (SCOPE_DENIED).
* Browser sessions with a remote x-node-id are blocked by hubOnlyGuard (HUB_ONLY_ENDPOINT)
* before any upstream hop, since SSO config is control-plane identity state.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'http';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import jwt from 'jsonwebtoken';
import { createTestApiToken } from './helpers/apiTokenTestHelper';
import { PROXY_ROLE_HEADER } from '../services/license-headers';
let tmpDir: string;
let app: import('express').Express;
let adminToken: string;
let fullAdminApiToken: string;
let remoteNodeId: number;
interface CapturedHop {
method: string;
url: string;
roleHeader: string | undefined;
}
const capturedHops: CapturedHop[] = [];
function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void {
into.push({
method: req.method ?? '',
url: req.url ?? '',
roleHeader: req.headers[PROXY_ROLE_HEADER] as string | undefined,
});
}
function createRemoteServer(): http.Server {
return http.createServer((req, res) => {
// Always serve /api/meta so the proxy can discover capabilities
if (req.url?.startsWith('/api/meta')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ version: '0.97.1', capabilities: ['cross-node-rbac'] }));
return;
}
captureHop(req, capturedHops);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
}
async function listen(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return (server.address() as import('net').AddressInfo).port;
}
let remoteServer: http.Server;
let remotePort: number;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
adminToken = jwt.sign({ username: 'testadmin', role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1h' });
// Create a full-admin API token via the shared test helper
const admin = db.getUserByUsername('testadmin');
fullAdminApiToken = createTestApiToken({
db: DatabaseService,
scope: 'full-admin',
userId: admin!.id,
name: `sso-config-proxy-test-${Date.now()}`,
});
// Set up the remote server
remoteServer = createRemoteServer();
remotePort = await listen(remoteServer);
remoteNodeId = db.addNode({
name: 'sso-config-remote',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: `http://127.0.0.1:${remotePort}`,
api_token: 'remote-sso-token',
});
});
afterAll(async () => {
await new Promise<void>((resolve) => remoteServer.close(() => resolve()));
cleanupTestDb(tmpDir);
});
beforeEach(() => {
capturedHops.length = 0;
});
describe('Hub-side rejection for remote SSO config', () => {
const blockedRequests: Array<{ method: 'get' | 'put'; path: string; body?: Record<string, unknown> }> = [
{ method: 'get', path: '/api/sso/config/role-sync' },
{ method: 'get', path: '/api/sso/config' },
{ method: 'put', path: '/api/sso/config/role-sync', body: { enabled: true } },
{ method: 'get', path: '/api/sso/config/ldap' },
{ method: 'put', path: '/api/sso/config/ldap', body: { enabled: true } },
// Case variants: Express routes case-insensitively, so the hub guard must
// reject them too (regression for a case-sensitive guard bypass).
{ method: 'get', path: '/api/SSO/config/role-sync' },
{ method: 'put', path: '/api/SSO/config/role-sync', body: { enabled: true } },
{ method: 'get', path: '/api/Sso/Config/Role-Sync' },
{ method: 'put', path: '/api/Sso/Config/Role-Sync', body: { enabled: true } },
];
for (const { method, path, body } of blockedRequests) {
it(`returns 403 HUB_ONLY_ENDPOINT for ${method.toUpperCase()} ${path} (API token), no upstream hop`, async () => {
let req = request(app)[method](path)
.set('Authorization', `Bearer ${fullAdminApiToken}`)
.set('x-node-id', String(remoteNodeId));
if (body) req = req.send(body);
const res = await req;
expect(res.status).toBe(403);
expect(res.body.code).toBe('HUB_ONLY_ENDPOINT');
expect(capturedHops).toHaveLength(0);
});
}
it('Browser admin session is rejected by hubOnlyGuard for GET /api/sso/config/role-sync', async () => {
const res = await request(app)
.get('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body.code).toBe('HUB_ONLY_ENDPOINT');
expect(capturedHops).toHaveLength(0);
});
it('Browser admin session is rejected by hubOnlyGuard for PUT /api/sso/config/role-sync', async () => {
const res = await request(app)
.put('/api/sso/config/role-sync')
.set('Authorization', `Bearer ${adminToken}`)
.set('x-node-id', String(remoteNodeId))
.send({ enabled: true });
expect(res.status).toBe(403);
expect(res.body.code).toBe('HUB_ONLY_ENDPOINT');
expect(capturedHops).toHaveLength(0);
});
});