mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
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.
This commit is contained in:
@@ -22,13 +22,16 @@ describe('hubOnlyGuard', () => {
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let remoteNodeId: number;
|
||||
let proxyRemoteNodeId: number;
|
||||
let pilotRemoteNodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
remoteNodeId = DatabaseService.getInstance().addNode({
|
||||
const db = DatabaseService.getInstance();
|
||||
remoteNodeId = db.addNode({
|
||||
name: 'hub-only-remote',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
@@ -36,6 +39,23 @@ describe('hubOnlyGuard', () => {
|
||||
api_url: 'http://127.0.0.1:1',
|
||||
api_token: 'hub-only-token',
|
||||
});
|
||||
proxyRemoteNodeId = db.addNode({
|
||||
name: 'hub-only-sso-proxy',
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: 'http://127.0.0.1:1',
|
||||
api_token: 'hub-only-sso-proxy-token',
|
||||
});
|
||||
pilotRemoteNodeId = db.addNode({
|
||||
name: 'hub-only-sso-pilot',
|
||||
type: 'remote',
|
||||
mode: 'pilot_agent',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_token: 'hub-only-sso-pilot-token',
|
||||
});
|
||||
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
@@ -286,4 +306,66 @@ describe('hubOnlyGuard', () => {
|
||||
|
||||
expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('rejects mixed-case /api/Secrets with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/Secrets')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
const ssoCases = [
|
||||
{ method: 'get' as const, path: '/api/sso/config' },
|
||||
{ method: 'get' as const, path: '/api/sso/config/ldap' },
|
||||
{ method: 'put' as const, path: '/api/sso/config/ldap', body: { enabled: true } },
|
||||
{ method: 'delete' as const, path: '/api/sso/config/ldap' },
|
||||
{ method: 'post' as const, path: '/api/sso/config/ldap/test' },
|
||||
{ method: 'get' as const, path: '/api/sso/config/role-sync' },
|
||||
{ method: 'put' as const, path: '/api/sso/config/role-sync', body: { enabled: true } },
|
||||
{ method: 'get' as const, path: '/api/sso/auth-mode' },
|
||||
{ method: 'put' as const, path: '/api/sso/auth-mode', body: { mode: 'local_and_sso' } },
|
||||
];
|
||||
|
||||
for (const remoteLabel of ['proxy', 'pilot_agent'] as const) {
|
||||
const nodeIdForLabel = () => (remoteLabel === 'proxy' ? proxyRemoteNodeId : pilotRemoteNodeId);
|
||||
|
||||
for (const { method, path, body } of ssoCases) {
|
||||
it(`rejects ${method.toUpperCase()} ${path} with 403 for ${remoteLabel} remote node`, async () => {
|
||||
const req = request(app)[method](path)
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(nodeIdForLabel()));
|
||||
if (body !== undefined) {
|
||||
req.send(body);
|
||||
}
|
||||
const res = await req;
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('rejects mixed-case GET /api/SSO/config with 403 for proxy remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/SSO/config')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(proxyRemoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('returns guard 403 (not proxy 503) for pilot_agent without a live tunnel', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/sso/config')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(pilotRemoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
expect(res.status).not.toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Hub → remote proxy coverage for SSO configuration API-token rejection.
|
||||
* The remote proxy replaces incoming credentials with a node-to-node JWT,
|
||||
* so destination-side rejectApiTokenScope cannot identify the original API
|
||||
* token. This test proves that the hub-side guard in remoteNodeProxy blocks
|
||||
* full-admin API tokens targeting /api/sso/config on remote nodes.
|
||||
* 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';
|
||||
@@ -98,7 +97,7 @@ beforeEach(() => {
|
||||
capturedHops.length = 0;
|
||||
});
|
||||
|
||||
describe('Hub-side API-token rejection for remote SSO config', () => {
|
||||
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' },
|
||||
@@ -114,7 +113,7 @@ describe('Hub-side API-token rejection for remote SSO config', () => {
|
||||
];
|
||||
|
||||
for (const { method, path, body } of blockedRequests) {
|
||||
it(`returns 403 SCOPE_DENIED for ${method.toUpperCase()} ${path}, no upstream hop`, async () => {
|
||||
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));
|
||||
@@ -122,35 +121,31 @@ describe('Hub-side API-token rejection for remote SSO config', () => {
|
||||
const res = await req;
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('SCOPE_DENIED');
|
||||
expect(res.body.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
expect(capturedHops).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('Browser admin session reaches upstream for GET /api/sso/config/role-sync', async () => {
|
||||
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(200);
|
||||
const hop = capturedHops.find((h) => h.url?.includes('/sso/config/role-sync'));
|
||||
expect(hop).toBeDefined();
|
||||
expect(hop!.method).toBe('GET');
|
||||
expect(hop!.roleHeader).toBe('admin');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
expect(capturedHops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Browser admin session reaches upstream for PUT /api/sso/config/role-sync', async () => {
|
||||
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(200);
|
||||
const hop = capturedHops.find((h) => h.url?.includes('/sso/config/role-sync'));
|
||||
expect(hop).toBeDefined();
|
||||
expect(hop!.method).toBe('PUT');
|
||||
expect(hop!.roleHeader).toBe('admin');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
expect(capturedHops).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@ export function isProxyExemptPath(path: string): boolean {
|
||||
// Path prefixes that are hub-only: they must be served on the instance you are
|
||||
// signed into and never proxied to a remote node. This covers state owned by
|
||||
// the local hub (centralized audit, fleet schedules, notification routing
|
||||
// rules, the admin-only aggregated logs feed and its stream counters) and
|
||||
// rules, the admin-only aggregated logs feed and its stream counters), SSO
|
||||
// configuration and authentication mode (control-plane identity state), and
|
||||
// private registry credentials, which are stored and managed per instance.
|
||||
// Blueprints and node labels are hub-owned too: the hub is the only instance
|
||||
// that holds the desired-state definitions and the label set its placement
|
||||
@@ -64,13 +65,16 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [
|
||||
'/api/blueprints/',
|
||||
'/api/node-labels/',
|
||||
'/api/registry-delivery/',
|
||||
'/api/sso/',
|
||||
];
|
||||
|
||||
/** Returns true when the path is hub-only and must not be proxied to a remote node. */
|
||||
export function isHubOnlyPath(path: string): boolean {
|
||||
const normalized = path.toLowerCase();
|
||||
for (const prefix of HUB_ONLY_PREFIXES) {
|
||||
if (path.startsWith(prefix)) return true;
|
||||
if (path === prefix.slice(0, -1)) return true;
|
||||
const normalizedPrefix = prefix.toLowerCase();
|
||||
if (normalized.startsWith(normalizedPrefix)) return true;
|
||||
if (normalized === normalizedPrefix.slice(0, -1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user