mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user