feat: add per-route CORS origin allowlist for HTTP triggers - #10833
feat: add per-route CORS origin allowlist for HTTP triggers#10833hugocasa wants to merge 13 commits into
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying windmill with
|
| Latest commit: |
8a9a028
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://be817055.windmill.pages.dev |
| Branch Preview URL: | https://http-trigger-cors-config.windmill.pages.dev |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xists Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/review |
|
Claude finished @hugocasa's task in 5m 45s —— View job cc @hugocasa Should address issues before merging: empty The design is sound and the hard part — moving the CORS decision into the middleware so the preflight is covered by the same code that stamps the response — is done well: the origin is never reflected unchecked, FindingsP1 —
P2 — frontend
P2 —
P2 — unrelated formatting churn in Seven blank-line deletions and three rewraps ( P2 —
AGENTS.md complianceMigration created with the right tooling and reversible; sqlx cache regenerated (5 new entries) rather than hand-edited; Test coverageBackend — 12 new unit tests in Frontend — Manual verification still worth doing before merge — the PR's test plan is unusually thorough (real-browser preflight rejection, percent-encoded path equivalence, route-existence oracle, hot reload of the setting) and covers the paths I would otherwise have asked for. What it does not cover is the case above: set a route's --- · branch |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| let effective = match route_allowed_origins { | ||
| Some(route_allowed_origins) => route_allowed_origins.clone(), | ||
| None => HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS.load().as_ref().clone(), | ||
| }; | ||
|
|
||
| (!effective.is_empty() && !allows_any_origin(&effective)).then_some(effective) |
There was a problem hiding this comment.
P1 — an explicit empty allowlist fails open, and escapes the instance default.
Some([]) short-circuits on !effective.is_empty() and returns None, which the middleware reads as "no restriction" and answers with Access-Control-Allow-Origin: *. So a route stored with allowed_origins = '{}' is more permissive than one left NULL on an instance that has http_route_default_allowed_origins set — the empty list bypasses the default entirely.
That contradicts three places that document the opposite:
- the PR description table: "
Route list is []→ A restriction matching nothing — distinct fromNULL, which inherits" - the test comment at
lib.rs:665: "An empty route list is a restriction that matches nothing, distinct fromNULLwhich inherits the instance default" — while the assertion right below it pinsNone - by implication, the OpenAPI description ("When set, the list governs both the preflight and the response")
The UI blocks it (Enter at least one origin), but validate_allowed_origins(&[]) is Ok, HttpConfigRequest accepts "allowed_origins": [], and the insert/update store it verbatim — so anything driving the API directly (CLI, git-sync'd trigger yaml, the copilot tool whose Zod schema now accepts z.array(z.string())) can reach it, and the failure direction is open.
Either make Some(list) always a restriction and only fall back to the default on None:
match route_allowed_origins {
Some(list) if allows_any_origin(list) => None,
Some(list) => Some(list.clone()),
None => {
let default = HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS.load().as_ref().clone();
(!default.is_empty() && !allows_any_origin(&default)).then_some(default)
}
}…or normalize [] to NULL at write time and fix the doc/test comments to say "an empty list is stored as NULL and inherits". Right now the code and every description of it disagree.
| // Mirrors `validate_allowed_origins` in windmill-trigger-http so the error | ||
| // shows before saving rather than as a 400 from the API. | ||
| function originError(origin: string): string | undefined { | ||
| if (origin === '*') return undefined | ||
| const [scheme, ...rest] = origin.split('://') | ||
| if (rest.length !== 1) return `'${origin}' is missing a scheme, such as https://` | ||
| const host = rest[0] | ||
| if (host === '') return `'${origin}' is missing a host` | ||
| if (host.includes('/')) return `'${origin}' must not contain a path or trailing slash` | ||
| if (host.includes('?') || host.includes('#')) | ||
| return `'${origin}' must not contain a query or fragment` | ||
| if (host.includes('@')) return `'${origin}' must not contain userinfo` | ||
| if (!/^[A-Za-z0-9.+-]+$/.test(scheme)) return `'${origin}' has an invalid scheme` | ||
| return undefined | ||
| } |
There was a problem hiding this comment.
P2 — this does not fully mirror validate_allowed_origins, so some entries still 400 from the API.
The backend's first check is origin.chars().all(|c| c.is_ascii_graphic()), which rejects embedded whitespace and any non-ASCII. originError has no equivalent, so https://a b.com (space inside the host, not trimmed by parseAllowedOrigins) and https://münchen.de both pass the inline check, enable Save, and then come back as a 400 — the exact thing the comment above says this function exists to avoid. One extra guard covers both:
if (!/^[\x21-\x7e]+$/.test(origin)) return `'${origin}' must contain only visible ASCII, with no whitespace`| if method == http::Method::OPTIONS { | ||
| req.headers() | ||
| .get(http::header::ACCESS_CONTROL_REQUEST_METHOD) | ||
| .and_then(|method| method.to_str().ok()) | ||
| .and_then(|method| http::Method::try_from(method).ok()) | ||
| .as_ref() | ||
| .and_then(|method| HttpMethod::try_from(method).ok()) | ||
| } else if method == http::Method::HEAD { | ||
| Some(HttpMethod::Get) |
There was a problem hiding this comment.
P2 — HEAD is folded into Get for the real request but not for its preflight.
HttpMethod::try_from(&Method::HEAD) returns Err (lib.rs:84-96), so a preflight carrying Access-Control-Request-Method: HEAD — which browsers do send when the HEAD request carries a non-safelisted header, e.g. Content-Type: application/json — resolves to None, and the preflight is answered with the permissive * / full method list even for a route under an allowlist. The subsequent HEAD then takes the else if branch, resolves the GET router, and gets the restricted answer, so the two disagree.
No leak follows (the .head(|| async { "" }) handler returns an empty body, and the real response is still restricted), but the same mapping in both branches would be one line:
.and_then(|method| if method == http::Method::HEAD { Some(HttpMethod::Get) } else { HttpMethod::try_from(&method).ok() })Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @hugocasa's task in 4m 12s —— View job cc @hugocasa Mergeable, but should ideally address nits: Re-reviewed at
I re-walked the parts the previous round only asserted rather than checked, and they hold: FindingsP2 —
P2 — unrelated rustfmt churn in Seven blank-line deletions and three rewraps ( P2 — The reset added on P2 — the "keeps the previous value" rationale doesn't hold on the boot path —
True for a hot reload, but Nit — comment budget. AGENTS.md complianceMigration created with Test coverageBackend — 12 unit tests in Still uncovered by anything automated is the piece this PR actually restructures: Frontend — Manual verification still worth doing — the previously-requested case is now the regression check for the fix: set a route's --- · · branch |
| instanceDefaultOrigins: string[] | ||
| ): boolean { | ||
| const effective = allowed_origins ?? instanceDefaultOrigins | ||
| return effective.length > 0 && !effective.includes('*') |
There was a problem hiding this comment.
P2 — this no longer mirrors effective_allowed_origins for the empty list.
Since 25c8fda the backend treats Some([]) as a restriction that matches nothing, but here allowed_origins = [] gives effective.length === 0 → false, so the CORS badge on the Advanced header reads "unrestricted" for a route that in fact denies every origin. The doc comment above says it mirrors the Rust function, which it now doesn't in exactly that case.
Only reachable for a route whose list was set to [] through the API (the UI blocks it), so it's cosmetic — but it's the one input where the two implementations disagree, and the comment claims they don't.
| return effective.length > 0 && !effective.includes('*') | |
| if (allowed_origins !== undefined) return !allowed_origins.includes('*') | |
| return instanceDefaultOrigins.length > 0 && !instanceDefaultOrigins.includes('*') | |
| } |
Codex Reviewcc @hugocasa Mergeable, but should ideally address nits: incomplete origin validation, editor misreports valid allowlist states, static-site CORS controls are hidden, raw HTML in the new Svelte component FindingsP2 — Origin validation accepts values no browser can send — The scheme check allows a digit or punctuation as the first character, while the authority is never parsed as a host and optional numeric port. Consequently, values such as P2 — The editor misreports two valid backend allowlist states — The backend explicitly accepts an instance default as a JSON array, but the editor converts every non-string setting to P2 — Static website triggers cannot view or configure CORS in the editor —
P2 — The new component uses raw HTML feedback elements — The validation and hint blocks introduce raw Test coverageBackend unit tests cover matching, wildcard/empty semantics, and several validation cases, but not settings parsing or middleware integration. Frontend component tests are not generally expected; focused coverage of the new pure helper states would guard the array/empty-list mismatch. Manually verify malformed schemes and ports are rejected through both trigger and settings APIs. Configure the instance default as a JSON array, open an API-created |
Pi Reviewcc @hugocasa Mergeable, but should ideally address nits: The P1 from the prior pass (empty allowlist failing open and silently escaping the instance default) is fixed: FindingsP2 — The doc comment claims it mirrors P2 — The reset added on cfg load closes the cross-trigger case, but Considered and dismissed
Test coverage
Manual verification already documented by the author (real-browser allow/deny, preflight, |
There was a problem hiding this comment.
6 issues found across 24 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="frontend/src/lib/components/instanceSettings.ts">
<violation number="1" location="frontend/src/lib/components/instanceSettings.ts:270">
P2: The new http_route_default_allowed_origins setting has no UI validation, but the backend rejects invalid origins on write (parse_allowed_origins_setting/validate_allowed_origins). Save is only blocked for settings that define isValid, and saveSettings() awaits setInstanceConfig without try/catch, so typing an invalid origin makes the whole bulk save of all instance settings fail with no visible error. Add isValid/error mirroring the backend rules (reject paths, queries, userinfo, whitespace, non-ASCII, "Origin: null") like email_domain/webhook_base_url do, so bad input is caught before submit.</violation>
</file>
<file name="backend/windmill-api-settings/src/lib.rs">
<violation number="1" location="backend/windmill-api-settings/src/lib.rs:1021">
P1: When `http_route_default_allowed_origins` is supplied through `sync-config` or operator reconciliation, this hook is bypassed because those paths apply settings directly. Validate this key in the shared declarative write path too; otherwise malformed values persist and runtime keeps the empty allowlist, leaving CORS unrestricted.</violation>
</file>
<file name="backend/src/monitor.rs">
<violation number="1" location="backend/src/monitor.rs:427">
P1: When the global-settings read fails while route lookup still succeeds, `SettingsPass` skips this applier, leaving the new `ArcSwap` empty; middleware then treats the default as unset and emits `*`. Preserve a last-known value or mark the default unresolved and omit CORS until it is successfully read, otherwise a transient settings-query failure bypasses a configured allowlist.</violation>
</file>
<file name="backend/windmill-api/src/triggers/http/handler.rs">
<violation number="1" location="backend/windmill-api/src/triggers/http/handler.rs:220">
P2: When router resolution fails but the route later succeeds, `wm_headers` can leave an `Access-Control-Allow-Origin` header in the response. Remove that header for `CorsRouteLookup::Unavailable` instead of only skipping the fallback insertion.</violation>
<violation number="2" location="backend/windmill-api/src/triggers/http/handler.rs:235">
P2: When a runnable sets `Access-Control-Allow-Methods` through `wm_headers`, the restricted route keeps that value instead of the route-specific allowlist. Override the runnable method header whenever a restricted route is resolved.</violation>
</file>
<file name="frontend/src/lib/components/triggers/http/RouteEditorInner.svelte">
<violation number="1" location="frontend/src/lib/components/triggers/http/RouteEditorInner.svelte:1005">
P2: When the request-options tab unmounts after an invalid allowlist, `allowedOriginsError` remains bound in the parent and keeps Save disabled on other tabs without a visible error. Clear `allowedOriginsError` when leaving that tab.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| // Rejected at write time rather than at boot: a mistyped origin | ||
| // matches no request, so it would silently block the very app it | ||
| // names with nothing but a log line to go on. | ||
| windmill_common::global_settings::parse_allowed_origins_setting(Some(value))?; |
There was a problem hiding this comment.
P1: When http_route_default_allowed_origins is supplied through sync-config or operator reconciliation, this hook is bypassed because those paths apply settings directly. Validate this key in the shared declarative write path too; otherwise malformed values persist and runtime keeps the empty allowlist, leaving CORS unrestricted.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/windmill-api-settings/src/lib.rs, line 1021:
<comment>When `http_route_default_allowed_origins` is supplied through `sync-config` or operator reconciliation, this hook is bypassed because those paths apply settings directly. Validate this key in the shared declarative write path too; otherwise malformed values persist and runtime keeps the empty allowlist, leaving CORS unrestricted.</comment>
<file context>
@@ -1014,6 +1014,12 @@ async fn run_setting_pre_write_hook(
+ // Rejected at write time rather than at boot: a mistyped origin
+ // matches no request, so it would silently block the very app it
+ // names with nothing but a log line to go on.
+ windmill_common::global_settings::parse_allowed_origins_setting(Some(value))?;
+ }
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
</file context>
| apply_app_workspaced_route_setting(v) | ||
| }); | ||
| pass.setting( | ||
| HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, |
There was a problem hiding this comment.
P1: When the global-settings read fails while route lookup still succeeds, SettingsPass skips this applier, leaving the new ArcSwap empty; middleware then treats the default as unset and emits *. Preserve a last-known value or mark the default unresolved and omit CORS until it is successfully read, otherwise a transient settings-query failure bypasses a configured allowlist.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/monitor.rs, line 427:
<comment>When the global-settings read fails while route lookup still succeeds, `SettingsPass` skips this applier, leaving the new `ArcSwap` empty; middleware then treats the default as unset and emits `*`. Preserve a last-known value or mark the default unresolved and omit CORS until it is successfully read, otherwise a transient settings-query failure bypasses a configured allowlist.</comment>
<file context>
@@ -420,6 +423,18 @@ pub async fn initial_load(
apply_app_workspaced_route_setting(v)
});
+ pass.setting(
+ HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
+ false,
+ |v| async move {
</file context>
| // Origin, and a shared cache that ignores it would hand one origin's | ||
| // response to another. | ||
| headers.append(http::header::VARY, http::HeaderValue::from_static("origin")); | ||
| } else if !not_insert_origin && !matches!(route, CorsRouteLookup::Unavailable) { |
There was a problem hiding this comment.
P2: When router resolution fails but the route later succeeds, wm_headers can leave an Access-Control-Allow-Origin header in the response. Remove that header for CorsRouteLookup::Unavailable instead of only skipping the fallback insertion.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/windmill-api/src/triggers/http/handler.rs, line 220:
<comment>When router resolution fails but the route later succeeds, `wm_headers` can leave an `Access-Control-Allow-Origin` header in the response. Remove that header for `CorsRouteLookup::Unavailable` instead of only skipping the fallback insertion.</comment>
<file context>
@@ -67,18 +178,68 @@ async fn conditional_cors_middleware(
+ // Origin, and a shared cache that ignores it would hand one origin's
+ // response to another.
+ headers.append(http::header::VARY, http::HeaderValue::from_static("origin"));
+ } else if !not_insert_origin && !matches!(route, CorsRouteLookup::Unavailable) {
headers.insert(
http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
</file context>
| } else if !not_insert_origin && !matches!(route, CorsRouteLookup::Unavailable) { | |
| } else if matches!(route, CorsRouteLookup::Unavailable) { | |
| headers.remove(http::header::ACCESS_CONTROL_ALLOW_ORIGIN); | |
| } else if !not_insert_origin { |
| description: | ||
| 'Origins that HTTP routes allow to call them from a browser when the route sets none of its own. A route overrides this with its own list, and opts out entirely by setting its allowed origins to *. Leave empty to let every route be called from any origin.', | ||
| key: 'http_route_default_allowed_origins', | ||
| fieldType: 'text', |
There was a problem hiding this comment.
P2: The new http_route_default_allowed_origins setting has no UI validation, but the backend rejects invalid origins on write (parse_allowed_origins_setting/validate_allowed_origins). Save is only blocked for settings that define isValid, and saveSettings() awaits setInstanceConfig without try/catch, so typing an invalid origin makes the whole bulk save of all instance settings fail with no visible error. Add isValid/error mirroring the backend rules (reject paths, queries, userinfo, whitespace, non-ASCII, "Origin: null") like email_domain/webhook_base_url do, so bad input is caught before submit.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/lib/components/instanceSettings.ts, line 270:
<comment>The new http_route_default_allowed_origins setting has no UI validation, but the backend rejects invalid origins on write (parse_allowed_origins_setting/validate_allowed_origins). Save is only blocked for settings that define isValid, and saveSettings() awaits setInstanceConfig without try/catch, so typing an invalid origin makes the whole bulk save of all instance settings fail with no visible error. Add isValid/error mirroring the backend rules (reject paths, queries, userinfo, whitespace, non-ASCII, "Origin: null") like email_domain/webhook_base_url do, so bad input is caught before submit.</comment>
<file context>
@@ -262,6 +262,17 @@ export const settings: Record<string, Setting[]> = {
+ description:
+ 'Origins that HTTP routes allow to call them from a browser when the route sets none of its own. A route overrides this with its own list, and opts out entirely by setting its allowed origins to *. Leave empty to let every route be called from any origin.',
+ key: 'http_route_default_allowed_origins',
+ fieldType: 'text',
+ placeholder: 'https://app.example.com, https://admin.example.com',
+ storage: 'setting',
</file context>
| headers.insert( | ||
| http::header::ACCESS_CONTROL_ALLOW_METHODS, | ||
| http::HeaderValue::from_static("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"), | ||
| http::HeaderValue::from_static(match restricted_route.map(|route| route.http_method) { |
There was a problem hiding this comment.
P2: When a runnable sets Access-Control-Allow-Methods through wm_headers, the restricted route keeps that value instead of the route-specific allowlist. Override the runnable method header whenever a restricted route is resolved.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/windmill-api/src/triggers/http/handler.rs, line 235:
<comment>When a runnable sets `Access-Control-Allow-Methods` through `wm_headers`, the restricted route keeps that value instead of the route-specific allowlist. Override the runnable method header whenever a restricted route is resolved.</comment>
<file context>
@@ -67,18 +178,68 @@ async fn conditional_cors_middleware(
headers.insert(
http::header::ACCESS_CONTROL_ALLOW_METHODS,
- http::HeaderValue::from_static("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
+ http::HeaderValue::from_static(match restricted_route.map(|route| route.http_method) {
+ Some(HttpMethod::Get) => "GET, OPTIONS",
+ Some(HttpMethod::Post) => "POST, OPTIONS",
</file context>
|
|
||
| <RouteCorsOption | ||
| bind:allowed_origins | ||
| bind:error={allowedOriginsError} |
There was a problem hiding this comment.
P2: When the request-options tab unmounts after an invalid allowlist, allowedOriginsError remains bound in the parent and keeps Save disabled on other tabs without a visible error. Clear allowedOriginsError when leaving that tab.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/lib/components/triggers/http/RouteEditorInner.svelte, line 1005:
<comment>When the request-options tab unmounts after an invalid allowlist, `allowedOriginsError` remains bound in the parent and keeps Save disabled on other tabs without a visible error. Clear `allowedOriginsError` when leaving that tab.</comment>
<file context>
@@ -961,6 +999,14 @@
+
+ <RouteCorsOption
+ bind:allowed_origins
+ bind:error={allowedOriginsError}
+ {instanceDefaultOrigins}
+ disabled={!can_write}
</file context>
|
Round 2 nits addressed in 8a9a028 — thanks all three. Fixed
Not taking
Left open — the middleware integration test Claude suggests is a fair gap, and I have not written it. |
Codex Reviewcc @hugocasa FindingsP1 — Invalid-edit rollback can restore another route’s CORS policy —
P2 — Valid deny-all routes cannot be edited in the UI — The backend deliberately accepts P2 — Origin validation still accepts authorities browsers reject — The final validation only requires a nonempty host and digit-only port. Values such as Test coverageBackend unit tests cover matching, wildcard/empty semantics, and selected validation cases, but there is still no middleware integration test covering preflight and actual-response consistency. Frontend component tests are not generally expected; the new pure parsing/restriction helpers have no focused coverage. Manually open an unrestricted route followed by a restricted route, make the second route’s origin field invalid, switch tabs, and confirm its original policy remains intact when saving another change. Create an Verify impossible hosts, malformed IPv6 literals, and ports above 65535 are rejected by both the trigger and settings APIs and by inline validation. |
Pi ReviewGood to merge. I reviewed the full diff at head The core security properties hold. Considered and dismissed
Test coverage
The author's manual verification matrix (real-browser preflight across two ports, percent-encoded path resolution, write-time rejection through both the trigger and settings APIs, fork-clone column carry, hot reload without restart) covers the surfaces that matter and was exercised against a running |
There was a problem hiding this comment.
3 issues found across 6 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="backend/windmill-common/src/global_settings.rs">
<violation number="1" location="backend/windmill-common/src/global_settings.rs:323">
P2: Malformed host/bracket entries pass write-time validation and then can silently deny every browser origin instead of producing a configuration error. Validate the host grammar, including requiring brackets to contain a valid IPv6 literal and rejecting brackets or other invalid host characters in regular hosts.</violation>
</file>
<file name="frontend/src/lib/components/triggers/http/RouteCorsOption.svelte">
<violation number="1" location="frontend/src/lib/components/triggers/http/RouteCorsOption.svelte:103">
P1: When an existing route loads after this component mounts, `mountedWith` stays at the pre-load value. After a malformed edit and tab switch, cleanup restores that stale value, potentially changing the route's allowlist to `undefined` (allow all); update the snapshot whenever the parent replaces `allowed_origins`.</violation>
<violation number="2" location="frontend/src/lib/components/triggers/http/RouteCorsOption.svelte:103">
P2: When the prop is replaced from outside (draft apply / reset to deployed) the re-seed effect updates raw/restricted, but mountedWith keeps the value captured at component setup. If the user then types an invalid entry (setting error) and leaves the tab, the unmount cleanup restores that stale mount-time value, discarding the restriction that was just applied from the draft/deployed config. Make mountedWith reactive and re-snapshot it whenever the external-write path re-seeds, so the abandoned-edit restore preserves the latest applied value.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| // it: `error` alone would keep Save disabled from a screen that cannot show | ||
| // why, and clearing it alone would let a half-typed list save as a real | ||
| // restriction. Leaving restores what was there on arrival, never widening. | ||
| const mountedWith = allowed_origins |
There was a problem hiding this comment.
P1: When an existing route loads after this component mounts, mountedWith stays at the pre-load value. After a malformed edit and tab switch, cleanup restores that stale value, potentially changing the route's allowlist to undefined (allow all); update the snapshot whenever the parent replaces allowed_origins.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/lib/components/triggers/http/RouteCorsOption.svelte, line 103:
<comment>When an existing route loads after this component mounts, `mountedWith` stays at the pre-load value. After a malformed edit and tab switch, cleanup restores that stale value, potentially changing the route's allowlist to `undefined` (allow all); update the snapshot whenever the parent replaces `allowed_origins`.</comment>
<file context>
@@ -86,6 +96,16 @@
+ // it: `error` alone would keep Save disabled from a screen that cannot show
+ // why, and clearing it alone would let a half-typed list save as a real
+ // restriction. Leaving restores what was there on arrival, never widening.
+ const mountedWith = allowed_origins
+ $effect(() => () => {
+ if (error !== undefined) allowed_origins = mountedWith
</file context>
| // An IPv6 literal is bracketed, so its own colons are not the port | ||
| // separator: splitting on the last colon would read `http://[::1]` as | ||
| // host `[:` and reject an origin a browser really does send. | ||
| let (host, port) = match rest.strip_prefix('[') { |
There was a problem hiding this comment.
P2: Malformed host/bracket entries pass write-time validation and then can silently deny every browser origin instead of producing a configuration error. Validate the host grammar, including requiring brackets to contain a valid IPv6 literal and rejecting brackets or other invalid host characters in regular hosts.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/windmill-common/src/global_settings.rs, line 323:
<comment>Malformed host/bracket entries pass write-time validation and then can silently deny every browser origin instead of producing a configuration error. Validate the host grammar, including requiring brackets to contain a valid IPv6 literal and rejecting brackets or other invalid host characters in regular hosts.</comment>
<file context>
@@ -313,6 +316,30 @@ pub fn validate_allowed_origins(allowed_origins: &[String]) -> crate::error::Res
+ // An IPv6 literal is bracketed, so its own colons are not the port
+ // separator: splitting on the last colon would read `http://[::1]` as
+ // host `[:` and reject an origin a browser really does send.
+ let (host, port) = match rest.strip_prefix('[') {
+ Some(after_bracket) => match after_bracket.split_once(']') {
+ Some((host, "")) => (host, None),
</file context>
| // it: `error` alone would keep Save disabled from a screen that cannot show | ||
| // why, and clearing it alone would let a half-typed list save as a real | ||
| // restriction. Leaving restores what was there on arrival, never widening. | ||
| const mountedWith = allowed_origins |
There was a problem hiding this comment.
P2: When the prop is replaced from outside (draft apply / reset to deployed) the re-seed effect updates raw/restricted, but mountedWith keeps the value captured at component setup. If the user then types an invalid entry (setting error) and leaves the tab, the unmount cleanup restores that stale mount-time value, discarding the restriction that was just applied from the draft/deployed config. Make mountedWith reactive and re-snapshot it whenever the external-write path re-seeds, so the abandoned-edit restore preserves the latest applied value.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/lib/components/triggers/http/RouteCorsOption.svelte, line 103:
<comment>When the prop is replaced from outside (draft apply / reset to deployed) the re-seed effect updates raw/restricted, but mountedWith keeps the value captured at component setup. If the user then types an invalid entry (setting error) and leaves the tab, the unmount cleanup restores that stale mount-time value, discarding the restriction that was just applied from the draft/deployed config. Make mountedWith reactive and re-snapshot it whenever the external-write path re-seeds, so the abandoned-edit restore preserves the latest applied value.</comment>
<file context>
@@ -86,6 +96,16 @@
+ // it: `error` alone would keep Save disabled from a screen that cannot show
+ // why, and clearing it alone would let a half-typed list save as a real
+ // restriction. Leaving restores what was there on arrival, never widening.
+ const mountedWith = allowed_origins
+ $effect(() => () => {
+ if (error !== undefined) allowed_origins = mountedWith
</file context>
Summary
Closes #10826.
Every response from
/api/r/*is stampedAccess-Control-Allow-Origin: *, and a route owner has no way to narrow it. The documented escape hatch — returningwm_headersfrom the runnable — is narrower than it looks: it is applied byresult_to_response, which is only reached from the sync path, soasync,sync_sseand static-asset routes never had it either.The structural gap is the preflight.
OPTIONSwas answered by the router with an empty body before any trigger lookup happened, and the middleware then stamped the permissive defaults onto it because its skip-if-present logic only skips headers the response already carries. No amount ofwm_headerscan reach that — by the time the runnable returns, a route whose purpose is a side effect has already run.This adds an origin allowlist at two levels, enforced in the middleware for both the preflight and the response.
Resolution order
route
allowed_origins→http_route_default_allowed_originsinstance setting →*ACAO: *, andwm_headersstill wins on sync routesNULL["*"]wm_headersescape hatch[]NULL, which inheritsBecause the preflight is answered before any code runs, config is the only thing it can consult. So whenever an allowlist is in effect it also bounds
wm_headers: letting the response widen what the preflight advertised would make the two disagree and leave the allowlist bounding nothing. A route that genuinely computes its own origin opts out with["*"].The instance setting is a default, not a ceiling — a workspace user can still set
["*"]on their own route. It is a house-style convenience for self-hosted admins, not a tenant boundary; against an untrusted tenant the control isauthentication_method, since CORS only stops a browser reading a response, not the request itself. It is an instance setting rather than an env var so a malformed origin is rejected at write time — a typo matches no request, so via env it would silently block the very app it names.Access-Control-Allow-Credentialsis still never set, so this is not a cookie-CSRF change. The exposure it closes is unauthenticated routes, and routes whose credential is a header the calling page already holds.Workspace-level defaults are deliberately left out — a tenant can already do this per route, and inserting a workspace step into the resolution order later is non-breaking.
Changes
allowed_origins TEXT[]tohttp_triggerallowed_originsonTriggerRoute,HttpConfigandHttpConfigRequest, threaded through create, update, therefresh_routersquery, and the workspace-fork cloneHTTP_ROUTE_DEFAULT_ALLOWED_ORIGINSinstance setting, validated on write and hot-reloaded (no restart, and no router-cache bump — it is read per request)match_origin(exact, case-insensitive, echoes the request origin only on a match — never reflects unchecked) andvalidate_allowed_origins(rejects paths, queries, userinfo, whitespace, non-ASCII andnull, since a sandboxed iframe sendsOrigin: null), shared by the route field and the settingconditional_cors_middlewareresolves the trigger fromHTTP_ROUTERS_CACHEand is now the single place CORS headers are decided, covering the preflight without a second code path that could driftRouteCorsOption.svelte: a "Restrict origins" toggle and comma-separated field in Advanced → Request Options, with inline validation mirroring the backend, and the inherited instance default shown when the route sets noneScreenshots
A route's own allowlist, with inline validation mirroring the backend's rules:
With an instance default configured, the toggle relabels to "Override allowed origins" and names what is currently in effect, so an off toggle never reads as "callable from anywhere". The
CORSbadge on the Advanced header shows the restriction without the section being expanded:Test plan
Exercised against a running instance built with
--features quickjs,http_trigger(/api/r/*is not mounted in the default dev build).ACAO: *with the full method list on both preflight and response, andwm_headersstill overrides itVary: origin; disallowed → noACAOon both preflight and responseNULL, applies without a restart, and boundswm_headers["*"]on a route opts out of the instance default entirely —wm_headerswins again, full method list, noVarylocalhost:3140fetching the route onlocalhost:8140reads the body from an allowed origin; from a disallowed one Chromium reports "Response to preflight request doesn't pass access control check" and the job count is unchanged — the runnable never executes/api/r/corspr%6fbe) resolve to the same trigger the handler serves, so the allowlist cannot be stepped around by re-encoding a characterNULLCORSbadge on the Advanced header tracks the same rule the backend applies: on for a route list or an inherited default, off when nothing is configured or the route opted out with["*"]http_route_default_allowed_origins(so the inherited hint renders for the people who did not set it), whilelicense_keystays superadmin-onlycargo check --features http_triggerand--features http_trigger,enterprise,private; 112 unit tests inwindmill-trigger-http;npm run check0 errors; sqlx cache regenerated (+5 entries, 0 lost)Not exercised at runtime: the branch where the routers are genuinely unloadable because the database is unreachable. Reaching it needs a failed load and a cold cache at once, which is only reproducible by disrupting the Postgres shared with other worktrees. It fails closed by omitting the header.
🤖 Generated with Claude Code