mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +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);
|
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 () => {
|
it('accepts WebSocket upgrade with admin token', async () => {
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ username: TEST_USERNAME, role: 'admin' },
|
{ username: TEST_USERNAME, role: 'admin' },
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ import { NodeRegistry } from '../services/NodeRegistry';
|
|||||||
import { isDebugEnabled } from '../utils/debug';
|
import { isDebugEnabled } from '../utils/debug';
|
||||||
import { rejectUpgrade as reject } from './reject';
|
import { rejectUpgrade as reject } from './reject';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoped JWTs allowed on the generic `/ws` upgrade after the upgrade handler's
|
||||||
|
* earlier gates. Deny-by-default: mfa_pending, pilot_enroll, node_proxy (also
|
||||||
|
* rejected via isProxyToken), and any unknown future scope must not skip the
|
||||||
|
* session admin check.
|
||||||
|
*
|
||||||
|
* - api_token: restricted scopes blocked upstream for /ws
|
||||||
|
* - console_session: path + one-time jti consumed upstream
|
||||||
|
* - pilot_tunnel: machine credential for agent loopback; no further path gate
|
||||||
|
* (must stay allowed so hub-forwarded /ws still works on pilot agents)
|
||||||
|
*/
|
||||||
|
const GENERIC_WS_ALLOWED_SCOPES = new Set([
|
||||||
|
'api_token',
|
||||||
|
'console_session',
|
||||||
|
'pilot_tunnel',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header the deploy/update/down routes carry the per-deploy correlation id on,
|
* Header the deploy/update/down routes carry the per-deploy correlation id on,
|
||||||
* mirroring the `sessionId` the frontend sends in `{action:'connectTerminal'}`.
|
* mirroring the `sessionId` the frontend sends in `{action:'connectTerminal'}`.
|
||||||
@@ -53,12 +70,16 @@ export function handleGenericWs(
|
|||||||
|
|
||||||
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
||||||
|
|
||||||
// Admin enforcement: container exec requires admin role.
|
// Admin enforcement for unscoped session JWTs: DB user must be admin.
|
||||||
// console_session tokens are already admin-gated at creation time and
|
// Scoped JWTs are deny-by-default via GENERIC_WS_ALLOWED_SCOPES (each
|
||||||
// path/jti-gated in upgradeHandler. API tokens reaching this point have
|
// allowed scope is reduced earlier in the upgrade pipeline, except
|
||||||
// full-admin scope (read-only / deploy-only are blocked by the upgrade
|
// pilot_tunnel which is the loopback machine credential itself).
|
||||||
// handler's scope gate).
|
if (decoded.scope) {
|
||||||
if (!decoded.scope) {
|
if (!GENERIC_WS_ALLOWED_SCOPES.has(decoded.scope)) {
|
||||||
|
console.warn('[Exec] Rejected scoped token on /ws:', decoded.scope);
|
||||||
|
return reject(socket, 403, 'Forbidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
|
const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
|
||||||
if (!execUser) {
|
if (!execUser) {
|
||||||
console.warn('[Exec] User account not found:', decoded.username);
|
console.warn('[Exec] User account not found:', decoded.username);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { DatabaseService, type UserRole } from '../services/DatabaseService';
|
import { DatabaseService, type UserRole } from '../services/DatabaseService';
|
||||||
import { LicenseService } from '../services/LicenseService';
|
import { LicenseService } from '../services/LicenseService';
|
||||||
import { NodeRegistry } from '../services/NodeRegistry';
|
import { NodeRegistry } from '../services/NodeRegistry';
|
||||||
import { COOKIE_NAME } from '../helpers/constants';
|
import { COOKIE_NAME, MFA_PENDING_SCOPE } from '../helpers/constants';
|
||||||
import { handlePilotTunnel } from './pilotTunnel';
|
import { handlePilotTunnel } from './pilotTunnel';
|
||||||
import { handleMeshProxyTunnel } from './meshProxyTunnel';
|
import { handleMeshProxyTunnel } from './meshProxyTunnel';
|
||||||
import { handleNotificationsWs } from './notifications';
|
import { handleNotificationsWs } from './notifications';
|
||||||
@@ -212,6 +212,22 @@ export function attachUpgrade(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partial-auth (mfa_pending) and enroll-only (pilot_enroll) JWTs must not
|
||||||
|
// continue past shared cookie/Bearer auth. HTTP already rejects mfa_pending
|
||||||
|
// outside MFA routes; pilot_enroll is only valid on /api/pilot/tunnel
|
||||||
|
// (handled above). Reject here for every remaining WS path so handlers that
|
||||||
|
// do not re-check scope (logs, notifications, host console, etc.) cannot
|
||||||
|
// accept them. handleGenericWs used to treat any set scope as "already
|
||||||
|
// gated" and skip admin; that path is now deny-by-default too.
|
||||||
|
// pilot_tunnel is intentionally allowed: agent loopback injects it on every
|
||||||
|
// forwarded WS, including /ws container exec.
|
||||||
|
if (
|
||||||
|
decoded.scope === MFA_PENDING_SCOPE
|
||||||
|
|| decoded.scope === 'pilot_enroll'
|
||||||
|
) {
|
||||||
|
return reject(socket, 403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
||||||
const pathname = parsedUrl.pathname;
|
const pathname = parsedUrl.pathname;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user