fix: close unauthenticated DAP debugger program-mode launch bypass (#9829)

The /ws_debug debugger WebSocket gated JWT signature verification on inline
`code` being present (`if (code && REQUIRE_SIGNED_REQUESTS)`), so a
`program`-mode launch (naming an arbitrary server-side file path that is read
and executed) skipped verification entirely — even with
REQUIRE_SIGNED_DEBUG_REQUESTS=true. The WS handshake also performed no Origin
check, allowing cross-origin (CSWSH) drive-by from a malicious page.

- Enforce signing on every launch in both handlers (Python + Bun/TS): reject
  program-mode outright and require+verify a token for inline code.
- Add opt-in DEBUG_ALLOWED_ORIGINS allowlist enforced at the WS handshake.
- Default docker-compose REQUIRE_SIGNED_DEBUG_REQUESTS to true.
- Update THREAT_MODEL T8/EP15 to reflect the root cause and mitigation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-28 11:49:27 +02:00
committed by GitHub
parent da45e699c8
commit c0768de0ac
4 changed files with 70 additions and 11 deletions
+2 -2
View File
@@ -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 |
+34 -4
View File
@@ -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<string, unknown>) || {}
this.envVars = (args.env as Record<string, string>) || {}
// 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}`)
+32 -4
View File
@@ -222,6 +222,21 @@ function generateMainCallArgs(code: string, args: Record<string, unknown>): 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<string, unknown>) || {}
this.envVars = (args.env as Record<string, string>) || {}
// 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
+2 -1
View File
@@ -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