diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md index dc98a5a625..9501b5bf0d 100644 --- a/backend/THREAT_MODEL.md +++ b/backend/THREAT_MODEL.md @@ -88,7 +88,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers | stored user content → admin browser (same origin) | Admin session, account takeover | | EP13 Log/file reading & export endpoints | `service_logs`, `jobs_u/getupdate` log file read (symlinks), workspace/tarball export | authed/unauth request → arbitrary file or admin-only config | Arbitrary files, global settings | | EP14 Secret-value & resource-value caches | In-memory caches in `windmill-store` keyed (historically un-keyed) by path | cache lookup crossing identity/folder boundary | Secret variables, resource creds | -| EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS=false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets | +| EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS` now defaults to `true`; can still be overridden to `false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets | | EP16 Supply chain | Cached hub scripts, GitHub workflow actions, vendored deps, Docker base image | build/update-time input → host & build integrity | Worker host, build integrity | | EP17 Token lifecycle | Token create/rescope/refresh, script-issued JWTs | scoped caller → broader privilege | Tokens, accounts, isolation | @@ -103,7 +103,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e | | T6 | Disclosure of secrets, resource credentials, and workspace encryption keys across the authorization boundary (AI proxy, MCP, caches, export); database read additionally yields plaintext instance-level `global_settings` secrets | remote_auth | EP6, EP14, EP13 | Secret variables, encryption keys, resource creds, global settings | critical | likely | partially_mitigated | RLS on `$var:`, cache scoping by caller, admin checks on export; per-workspace secret *variables* encrypted at rest, but `global_settings` is plaintext under the default DB secret backend | GHSA-jwg4-v3cj-rvfm, GHSA-8m2p-2crh-9h3w, GHSA-6635-6fch-v8px, GHSA-437f-725p-7w84, GHSA-f27g-j463-q85w (CVE-2026-26964), GHSA-j679-v6vj-jfxc, GHSA-6vrr-fq33-qpfp, 0ba128afe7, 7836a4e733, ff8e39c69b | | T7 | Full instance compromise from insecure deployment defaults (dind control, default admin/`changeme`, exposed Postgres, publicly readable SUPERADMIN_SECRET) | remote_unauth | EP15 | All assets | critical | likely | partially_mitigated | first-time-setup warning on default admin; docs recommend hardening | GHSA-3vpp-vf62-wqp6, GHSA-24fr-44f8-fqwg (CVE-2026-29059), GHSA-6q36-5p3h-766j | -| T8 | Unauthenticated RCE via the Debugger WebSocket in the default `windmill_extra` configuration | remote_unauth | EP15 | Worker host, all assets | critical | possible | unmitigated | `REQUIRE_SIGNED_DEBUG_REQUESTS` exists but defaults to false | GHSA-725h-99vx-9xr4 | +| T8 | Unauthenticated RCE via the Debugger WebSocket: `/ws_debug/*` exposed by the gateway/ingress with the debugger service as the auth boundary; signature gate was bypassable via `program`-mode launches (read+exec an arbitrary server-side file path, never signed) even with signing on, and the WS handshake had no Origin check (CSWSH) | remote_unauth | EP15 | Worker host, all assets | critical | possible | partially_mitigated | `program`-mode launches now rejected when `REQUIRE_SIGNED_DEBUG_REQUESTS` is on (signing covers every launch, not just inline `code`); shipped `docker-compose` now defaults `REQUIRE_SIGNED_DEBUG_REQUESTS=true`; opt-in `DEBUG_ALLOWED_ORIGINS` allowlist rejects cross-origin handshakes. Residual: code default is secure but operators can still set `=false`; origin allowlist is opt-in | GHSA-725h-99vx-9xr4 | | T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) | | T10 | Unauthenticated disclosure of job results, args, logs, and admin config via missing-authz public endpoints | remote_unauth | EP2, EP13 | Job results/args/logs, global settings, scripts | high | likely | partially_mitigated | anonymous-job checks, log-endpoint authz hardening | GHSA-qfg7-x243-5hg4, GHSA-v448-fmm4-52fp, 108a88a180, bb90f4ce83 | | T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, or S3 download content-type | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0 | diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 0486814ff3..2b9ce46272 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -149,6 +149,22 @@ const logger = { const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000 const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false' +// Opt-in cross-origin protection (CSWSH defense-in-depth). When +// DEBUG_ALLOWED_ORIGINS is set (comma-separated list of origins), browser +// requests carrying a non-matching Origin header are rejected at the +// handshake. Non-browser clients send no Origin and are unaffected; code +// execution is independently gated by signed-token verification on launch. +const ALLOWED_ORIGINS = (process.env.DEBUG_ALLOWED_ORIGINS || '') + .split(',') + .map(o => o.trim()) + .filter(Boolean) + +function isOriginRejected(req: Request): boolean { + const origin = req.headers.get('origin') + if (!origin || ALLOWED_ORIGINS.length === 0) return false + return !ALLOWED_ORIGINS.includes(origin) +} + interface JWK { kty: string crv: string @@ -897,9 +913,18 @@ class PythonDebugSession extends BaseDebugSession { this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} - // Verify JWT token if code is provided (signed debug request) - // The token is passed in the launch arguments - if (code && REQUIRE_SIGNED_REQUESTS) { + // Enforce signing on every launch. The token is passed in the launch + // arguments and is verified against the inline `code` (see windmill-api-debug). + if (REQUIRE_SIGNED_REQUESTS) { + // The backend only signs inline `code`; a `program`-mode launch names an + // arbitrary server-side file path that gets read and executed and is never + // signed. Refuse it so it cannot bypass token verification entirely. + if (this.scriptPath) { + logger.error('Rejected program-mode launch: only signed inline code is permitted') + this.sendResponse(request, false, {}, 'program-mode launch is not permitted; submit signed code instead') + return + } + const token = args.token as string | undefined if (!token) { logger.error('No debug token provided but signed requests are required') @@ -907,7 +932,7 @@ class PythonDebugSession extends BaseDebugSession { return } - const verificationError = await verifyDebugToken(token, code) + const verificationError = await verifyDebugToken(token, code ?? '') if (verificationError) { logger.error(`Token verification failed: ${verificationError}`) this.sendResponse(request, false, {}, `Token verification failed: ${verificationError}`) @@ -1067,6 +1092,11 @@ const server = Bun.serve({ const url = new URL(req.url) const path = url.pathname + if (isOriginRejected(req)) { + logger.warn(`Rejected request from disallowed origin: ${req.headers.get('origin')}`) + return new Response('Forbidden origin', { status: 403 }) + } + // Handle WebSocket upgrade with path-based routing if (server.upgrade(req, { data: { path } })) { logger.info(`WS upgrade: ${path}`) diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts index 9caa0d4e61..d6ed8967ad 100644 --- a/debugger/dap_websocket_server_bun.ts +++ b/debugger/dap_websocket_server_bun.ts @@ -222,6 +222,21 @@ function generateMainCallArgs(code: string, args: Record): stri const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000 const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false' +// Opt-in cross-origin protection (CSWSH defense-in-depth); see +// dap_debug_service.ts for the rationale. Only enforced for this file's +// standalone Bun.serve entrypoint (the windmill-extra runtime imports the +// DebugSession class and runs the guarded server in dap_debug_service.ts). +const ALLOWED_ORIGINS = (process.env.DEBUG_ALLOWED_ORIGINS || '') + .split(',') + .map(o => o.trim()) + .filter(Boolean) + +function isOriginRejected(req: Request): boolean { + const origin = req.headers.get('origin') + if (!origin || ALLOWED_ORIGINS.length === 0) return false + return !ALLOWED_ORIGINS.includes(origin) +} + interface JWK { kty: string crv: string @@ -1428,9 +1443,18 @@ export class DebugSession { this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} - // Verify JWT token if code is provided (signed debug request) - // The token is passed in the launch arguments - if (code && REQUIRE_SIGNED_REQUESTS) { + // Enforce signing on every launch. The token is passed in the launch + // arguments and is verified against the inline `code` (see windmill-api-debug). + if (REQUIRE_SIGNED_REQUESTS) { + // The backend only signs inline `code`; a `program`-mode launch names an + // arbitrary server-side file path that gets read and executed and is never + // signed. Refuse it so it cannot bypass token verification entirely. + if (this.scriptPath) { + logger.error('Rejected program-mode launch: only signed inline code is permitted') + this.sendResponse(request, false, {}, 'program-mode launch is not permitted; submit signed code instead') + return + } + const token = args.token as string | undefined if (!token) { logger.error('No debug token provided but signed requests are required') @@ -1438,7 +1462,7 @@ export class DebugSession { return } - const verificationError = await verifyDebugToken(token, code) + const verificationError = await verifyDebugToken(token, code ?? '') if (verificationError) { logger.error(`Token verification failed: ${verificationError}`) this.sendResponse(request, false, {}, `Token verification failed: ${verificationError}`) @@ -2535,6 +2559,10 @@ if (import.meta.main) { hostname: host, port, fetch(req, server) { + if (isOriginRejected(req)) { + logger.warn(`Rejected request from disallowed origin: ${req.headers.get('origin')}`) + return new Response('Forbidden origin', { status: 403 }) + } // Upgrade to WebSocket if (server.upgrade(req)) { return undefined as unknown as Response diff --git a/docker-compose.yml b/docker-compose.yml index 8b30479481..60be2185b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -182,8 +182,9 @@ services: - ENABLE_DEBUGGER=true # Set to true to enable debugger - DEBUGGER_PORT=3003 # Debugger service port - ENABLE_NSJAIL=false # Set to true for nsjail sandboxing (requires privileged: true) - - REQUIRE_SIGNED_DEBUG_REQUESTS=false # Set to true to require JWT tokens for debug sessions + - REQUIRE_SIGNED_DEBUG_REQUESTS=true # Require backend-signed JWT tokens for debug sessions. Do NOT set to false on any internet-reachable deployment: it exposes an unauthenticated code-execution debugger. - WINDMILL_BASE_URL=http://windmill_server:8000 + # - DEBUG_ALLOWED_ORIGINS=https://your-windmill-host # Optional CSWSH hardening: comma-separated allowlist of browser Origins permitted to open debug WebSockets volumes: - lsp_cache:/pyls/.cache logging: *default-logging