mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
fix(auth): gate scoped JWTs on container-exec WebSocket (#1741)
Partial-auth (mfa_pending) and enroll-only (pilot_enroll) tokens skipped the admin check on /ws because any set scope was treated as already gated. Reject those scopes in the shared upgrade pipeline, and deny unknown scopes on the generic path while allowing api_token, console_session, and pilot_tunnel.
This commit is contained in:
@@ -355,6 +355,89 @@ describe('WebSocket upgrade - exec auth enforcement', () => {
|
||||
expect(code).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects WebSocket upgrade with mfa_pending token (403)', async () => {
|
||||
// Partial-auth must not open /ws (upgrade early-reject + generic deny-by-default).
|
||||
// Pre-fix: any set scope skipped the admin check and unlocked execContainer.
|
||||
const token = jwt.sign(
|
||||
{ scope: 'mfa_pending', user_id: 1, username: 'viewer' },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
|
||||
ws.on('error', () => resolve(0));
|
||||
});
|
||||
expect(code).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects WebSocket upgrade with pilot_enroll token (403)', async () => {
|
||||
const token = jwt.sign(
|
||||
{ scope: 'pilot_enroll', nodeId: 1, enrollNonce: 'test-nonce' },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '15m' },
|
||||
);
|
||||
const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
|
||||
ws.on('error', () => resolve(0));
|
||||
});
|
||||
expect(code).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects WebSocket upgrade with an unknown scoped JWT (403)', async () => {
|
||||
const token = jwt.sign({ scope: 'future_machine_scope' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
|
||||
ws.on('error', () => resolve(0));
|
||||
});
|
||||
expect(code).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts WebSocket upgrade with pilot_tunnel token (pilot loopback)', async () => {
|
||||
// Agent loopback injects pilot_tunnel on every forwarded WS, including /ws.
|
||||
const token = jwt.sign({ scope: 'pilot_tunnel', nodeId: 1 }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => {
|
||||
ws.close();
|
||||
resolve(true);
|
||||
});
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts WebSocket upgrade with container-exec console_session (remote exec)', async () => {
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'container-exec' });
|
||||
const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => {
|
||||
ws.close();
|
||||
resolve(true);
|
||||
});
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects host-console console_session on /ws (path gate before allowlist)', async () => {
|
||||
// Path mismatch is enforced in upgradeHandler (consoleSessionPathForPathname)
|
||||
// before the generic allowlist; allowlist must not be the sole path gate.
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'host-console' });
|
||||
const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
|
||||
ws.on('error', () => resolve(0));
|
||||
});
|
||||
expect(code).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts WebSocket upgrade with admin token', async () => {
|
||||
const token = jwt.sign(
|
||||
{ username: TEST_USERNAME, role: 'admin' },
|
||||
|
||||
Reference in New Issue
Block a user