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
co-authored by Claude Opus 4.8
parent da45e699c8
commit c0768de0ac
4 changed files with 70 additions and 11 deletions
+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