diff --git a/server.js b/server.js index a763ea8..0e71485 100644 --- a/server.js +++ b/server.js @@ -267,6 +267,23 @@ async function handleTerminalConnection(ws, url, connId) { } } + // Opening a shell requires the containers:exec permission, same as the REST exec endpoint. + if (ws.__auth && typeof globalThis.__canExecForUser === 'function') { + try { + const allowed = await globalThis.__canExecForUser(ws.__auth, envId); + if (!allowed) { + console.warn(`[WS] exec denied: user=${ws.__auth.username} envId=${envId}`); + ws.send(JSON.stringify({ type: 'error', message: 'Permission denied' })); + ws.close(1008, 'exec permission denied'); + return; + } + } catch (err) { + console.error('[WS] exec permission check failed:', err); + ws.close(1011, 'internal error'); + return; + } + } + try { // Resolve Docker target via SvelteKit app's database let target; diff --git a/src/lib/server/ws-auth.ts b/src/lib/server/ws-auth.ts index 524170c..efa9d5a 100644 --- a/src/lib/server/ws-auth.ts +++ b/src/lib/server/ws-auth.ts @@ -1,8 +1,9 @@ -import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth'; +import { validateSessionById, isAuthEnabled, SESSION_COOKIE, getUserPermissionsForEnvironment } from './auth'; import { validateApiToken } from './api-tokens'; import { isEnterprise } from './license'; import { userHasAdminRole, userCanAccessEnvironment } from './db'; import { parseCookieHeader } from '$lib/utils/cookie-parse'; +import { canExecDecision } from './ws-exec-core'; export interface WsUpgradeAuth { userId: number; @@ -68,6 +69,21 @@ export async function canAccessEnvForUser( return userCanAccessEnvironment(auth.userId, environmentId); } +/** + * Whether the WS user may open a container exec/terminal session. Mirrors the + * REST gate `auth.can('containers','exec',envId)`. Pure logic lives in + * ws-exec-core; this binds the real enterprise + permission deps. + */ +export async function canExecForUser( + auth: WsUpgradeAuth, + environmentId: number | undefined | null +): Promise { + return canExecDecision(auth, environmentId, { + isEnterprise, + getPerms: getUserPermissionsForEnvironment + }); +} + declare global { // eslint-disable-next-line no-var var __authenticateWsUpgrade: @@ -77,7 +93,12 @@ declare global { var __canAccessEnvForUser: | ((auth: WsUpgradeAuth, envId: number | undefined | null) => Promise) | undefined; + // eslint-disable-next-line no-var + var __canExecForUser: + | ((auth: WsUpgradeAuth, envId: number | undefined | null) => Promise) + | undefined; } globalThis.__authenticateWsUpgrade = authenticateWsUpgrade; globalThis.__canAccessEnvForUser = canAccessEnvForUser; +globalThis.__canExecForUser = canExecForUser; diff --git a/src/lib/server/ws-exec-core.ts b/src/lib/server/ws-exec-core.ts new file mode 100644 index 0000000..603d124 --- /dev/null +++ b/src/lib/server/ws-exec-core.ts @@ -0,0 +1,30 @@ +/** + * Pure decision core for the WS exec gate. No imports, so it is unit-testable in + * the bun runner without dragging in better-sqlite3 via auth/db. Deps are + * injected; ws-auth.ts binds the real ones. + * + * Mirrors the REST gate `auth.can('containers','exec',envId)`: free edition / + * auth-disabled / admin all pass, exactly as checkPermission() does; enterprise + * requires the env-scoped `containers:exec` permission. + */ +export interface ExecAuth { + userId: number; + isAdmin: boolean; + authDisabled: boolean; +} + +export async function canExecDecision( + auth: ExecAuth, + environmentId: number | undefined | null, + deps: { + isEnterprise: () => Promise; + getPerms: (userId: number, envId: number) => Promise<{ containers?: string[] }>; + } +): Promise { + if (auth.authDisabled) return true; + if (auth.isAdmin) return true; + if (!(await deps.isEnterprise())) return true; + if (environmentId == null) return false; + const perms = await deps.getPerms(auth.userId, environmentId); + return perms.containers?.includes('exec') ?? false; +} diff --git a/vite.config.ts b/vite.config.ts index 51bbb6c..c346338 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -619,6 +619,18 @@ function webSocketPlugin(): Plugin { } } + // Opening a shell requires the containers:exec permission, same as the REST exec endpoint. + const canExecFn = (globalThis as any).__canExecForUser; + if (typeof canExecFn === 'function') { + const allowed = await canExecFn(wsAuth, envId); + if (!allowed) { + console.warn(`[WS] exec denied: user=${wsAuth.username} envId=${envId}`); + ws.send(JSON.stringify({ type: 'error', message: 'Permission denied' })); + ws.close(1008, 'exec permission denied'); + return; + } + } + const target = getDockerTarget(envId); try {