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:
Anso
2026-07-30 18:21:59 -04:00
committed by GitHub
parent a1e2846d7d
commit ce4b91e90f
3 changed files with 127 additions and 7 deletions
+27 -6
View File
@@ -7,6 +7,23 @@ import { NodeRegistry } from '../services/NodeRegistry';
import { isDebugEnabled } from '../utils/debug';
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,
* mirroring the `sessionId` the frontend sends in `{action:'connectTerminal'}`.
@@ -53,12 +70,16 @@ export function handleGenericWs(
if (isProxyToken) return reject(socket, 403, 'Forbidden');
// Admin enforcement: container exec requires admin role.
// console_session tokens are already admin-gated at creation time and
// path/jti-gated in upgradeHandler. API tokens reaching this point have
// full-admin scope (read-only / deploy-only are blocked by the upgrade
// handler's scope gate).
if (!decoded.scope) {
// Admin enforcement for unscoped session JWTs: DB user must be admin.
// Scoped JWTs are deny-by-default via GENERIC_WS_ALLOWED_SCOPES (each
// allowed scope is reduced earlier in the upgrade pipeline, except
// pilot_tunnel which is the loopback machine credential itself).
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;
if (!execUser) {
console.warn('[Exec] User account not found:', decoded.username);
+17 -1
View File
@@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken';
import { DatabaseService, type UserRole } from '../services/DatabaseService';
import { LicenseService } from '../services/LicenseService';
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 { handleMeshProxyTunnel } from './meshProxyTunnel';
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 pathname = parsedUrl.pathname;