diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 5b78f84e3f..7edb793bea 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -14,7 +14,8 @@ import * as path from "node:path"; import process from "node:process"; import { Buffer } from "node:buffer"; import { writeFileSync } from "node:fs"; -import { readTextFile } from "../../utils/utils.ts"; +import { getHeaders, readTextFile } from "../../utils/utils.ts"; +import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; import { WebSocket, WebSocketServer } from "ws"; import { createFrameworkPlugins, @@ -1714,13 +1715,17 @@ async function streamJobWithSSE( const sseUrl = `${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true`; + const extraHeaders = getHeaders(); const response = await fetch(sseUrl, { headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}`, + ...extraHeaders, }, }); + await detectAuthGatewayChallenge(response, sseUrl); + if (!response.ok) { throw new Error( `SSE request failed: ${response.status} ${response.statusText}`, diff --git a/cli/src/commands/docs/docs.ts b/cli/src/commands/docs/docs.ts index d86d4ca367..fed91201e0 100644 --- a/cli/src/commands/docs/docs.ts +++ b/cli/src/commands/docs/docs.ts @@ -4,6 +4,8 @@ import * as log from "../../core/log.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; import { GlobalOptions } from "../../types.ts"; +import { getHeaders } from "../../utils/utils.ts"; +import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; interface DocContentItem { title: string; @@ -36,6 +38,7 @@ async function docs( console.log(colors.bold(`\nSearching Windmill docs...\n`)); + const extraHeaders = getHeaders(); let res: Response; try { res = await fetch(url, { @@ -43,6 +46,7 @@ async function docs( headers: { "Content-Type": "application/json", Authorization: `Bearer ${workspace.token}`, + ...extraHeaders, }, body: JSON.stringify({ query }), }); @@ -50,6 +54,8 @@ async function docs( throw new Error(`Network error connecting to ${workspace.remote}: ${e}`); } + await detectAuthGatewayChallenge(res, url); + if (res.status === 403) { log.info( "Windmill documentation search is an Enterprise Edition feature. Please upgrade to use this command." diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 0635cd42ea..f1e605d8ff 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -12,7 +12,8 @@ import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; import * as path from "node:path"; import { stringify as yamlStringify } from "yaml"; -import { deepEqual, readTextFile, readTextFileSync } from "../../utils/utils.ts"; +import { deepEqual, getHeaders, readTextFile, readTextFileSync } from "../../utils/utils.ts"; +import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; import * as wmill from "../../../gen/services.gen.ts"; import * as specificItems from "../../core/specific_items.ts"; import { getCurrentGitBranch } from "../../utils/git.ts"; @@ -725,6 +726,7 @@ async function createScript( // (same content, lockfile, and metadata) as a no-op, so the CLI does not // produce phantom git-sync / promotion commits on re-pushes. const skipIfNoop = "skip_if_noop=true"; + const extraHeaders = getHeaders(); if (!bundleContent) { try { const url = @@ -738,9 +740,11 @@ async function createScript( headers: { Authorization: `Bearer ${workspace.token}`, "Content-Type": "application/json", + ...extraHeaders, }, body: JSON.stringify(body), }); + await detectAuthGatewayChallenge(req, url); if (req.status != 201) { throw Error( `${req.status} - ${req.statusText} - ${await req.text()}` @@ -771,9 +775,13 @@ async function createScript( skipIfNoop; const req = await fetch(url, { method: "POST", - headers: { Authorization: `Bearer ${workspace.token} ` }, + headers: { + Authorization: `Bearer ${workspace.token} `, + ...extraHeaders, + }, body: form, }); + await detectAuthGatewayChallenge(req, url); if (req.status != 201) { throw Error( `Script snapshot creation was not successful: ${req.status} - ${ @@ -1587,12 +1595,18 @@ async function preview( workspace.workspaceId + "/jobs/run/preview_bundle"; + const extraHeaders = getHeaders(); const response = await fetch(url, { method: "POST", - headers: { Authorization: `Bearer ${workspace.token}` }, + headers: { + Authorization: `Bearer ${workspace.token}`, + ...extraHeaders, + }, body: form, }); + await detectAuthGatewayChallenge(response, url); + if (!response.ok) { throw new Error( `Preview failed: ${response.status} - ${response.statusText} - ${await response.text()}` diff --git a/cli/test/headers_env_var.test.ts b/cli/test/headers_env_var.test.ts new file mode 100644 index 0000000000..ad483f4d05 --- /dev/null +++ b/cli/test/headers_env_var.test.ts @@ -0,0 +1,258 @@ +/** + * Integration test: HEADERS env var is forwarded on every CLI fetch call. + * + * Spins up an auth-gateway proxy in front of the test backend that: + * - 403s + Cloudflare-style HTML if the request is missing CF-Access-Client-Id / + * CF-Access-Client-Secret (mirrors the real-world Cloudflare Access challenge), + * - otherwise reverse-proxies to the backend. + * + * Then runs `wmill sync push` with --base-url pointed at the proxy. If any fetch + * in the CLI bypasses HEADERS, the proxy returns the challenge page and the push + * fails (or the request is logged as un-authenticated). Negative case verifies + * the gateway actually rejects un-headered requests, so a passing positive case + * is meaningful. + * + * Regression coverage for #6421 and the script.ts pushScript / + * jobs/run/preview_bundle / app dev SSE fetch calls that used to skip getHeaders(). + */ + +import { expect, test } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import type { Server } from "bun"; +import { withTestBackend } from "./test_backend.ts"; + +const HEADER_NAMES = ["CF-Access-Client-Id", "CF-Access-Client-Secret"] as const; +const HEADER_VALUES = { + "CF-Access-Client-Id": "test-cf-id", + "CF-Access-Client-Secret": "test-cf-secret", +} as const; + +const HEADERS_ENV = HEADER_NAMES + .map((h) => `${h}: ${HEADER_VALUES[h]}`) + .join(", "); + +interface ProxyState { + authenticatedRequests: { method: string; path: string }[]; + rejectedRequests: { method: string; path: string }[]; +} + +interface RunningProxy { + server: Server; + state: ProxyState; + url: string; +} + +function startGatewayProxy(backendUrl: string): RunningProxy { + const state: ProxyState = { + authenticatedRequests: [], + rejectedRequests: [], + }; + + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const reqUrl = new URL(req.url); + const pathAndQuery = reqUrl.pathname + reqUrl.search; + + const id = req.headers.get("cf-access-client-id"); + const secret = req.headers.get("cf-access-client-secret"); + const passes = + id === HEADER_VALUES["CF-Access-Client-Id"] && + secret === HEADER_VALUES["CF-Access-Client-Secret"]; + + if (!passes) { + state.rejectedRequests.push({ method: req.method, path: pathAndQuery }); + return new Response( + '