Skip to content

feat: add per-route CORS origin allowlist for HTTP triggers - #10833

Draft
hugocasa wants to merge 6 commits into
mainfrom
http-trigger-cors-config
Draft

feat: add per-route CORS origin allowlist for HTTP triggers#10833
hugocasa wants to merge 6 commits into
mainfrom
http-trigger-cors-config

Conversation

@hugocasa

@hugocasa hugocasa commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #10826.

Every response from /api/r/* is stamped Access-Control-Allow-Origin: *, and a route owner has no way to narrow it. The documented escape hatch — returning wm_headers from the runnable — is narrower than it looks: it is applied by result_to_response, which is only reached from the sync path, so async, sync_sse and static-asset routes never had it either.

The structural gap is the preflight. OPTIONS was 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 of wm_headers can 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_originshttp_route_default_allowed_origins instance setting → *

State Behaviour
Neither configured (every instance today) Unchanged: ACAO: *, and wm_headers still wins on sync routes
Instance default set, route NULL The default governs the route
Route list set It governs, overriding the instance default
Route list is ["*"] Explicit opt-out: no restriction at all, back to the pre-feature behaviour including the wm_headers escape hatch
Route list is [] A restriction matching nothing — distinct from NULL, which inherits

Because 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 is authentication_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-Credentials is 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

  • Migration adding nullable allowed_origins TEXT[] to http_trigger
  • allowed_origins on TriggerRoute, HttpConfig and HttpConfigRequest, threaded through create, update, the refresh_routers query, and the workspace-fork clone
  • HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS instance 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) and validate_allowed_origins (rejects paths, queries, userinfo, whitespace, non-ASCII and null, since a sandboxed iframe sends Origin: null), shared by the route field and the setting
  • conditional_cors_middleware resolves the trigger from HTTP_ROUTERS_CACHE and is now the single place CORS headers are decided, covering the preflight without a second code path that could drift
  • RouteCorsOption.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 none

Screenshots

cors-allowlist

cors-validation

An inherited restriction is visible without expanding the section, and the field names the origins it inherited:

cors-badge

Test plan

Exercised against a running instance built with --features quickjs,http_trigger (/api/r/* is not mounted in the default dev build).

  • Backwards compatibility: with nothing configured at either level, a route answers ACAO: * with the full method list on both preflight and response, and wm_headers still overrides it
  • Allowed origin → echoed back with Vary: origin; disallowed → no ACAO on both preflight and response
  • Instance default governs a route with NULL, applies without a restart, and bounds wm_headers
  • A route's own list overrides the instance default (the default's origins are then denied)
  • ["*"] on a route opts out of the instance default entirely — wm_headers wins again, full method list, no Vary
  • Clearing the setting and the route restores pre-feature behaviour exactly
  • Real browser: a page on localhost:3140 fetching the route on localhost:8140 reads 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
  • Percent-encoded paths (/api/r/corspr%6fbe) resolve to the same trigger the handler serves, so the allowlist cannot be stepped around by re-encoding a character
  • An unresolved route answers identically to an unknown path, so this is not a route-existence oracle
  • Invalid origins rejected at write time by both the trigger API and the settings API; the UI blocks Save rather than persisting NULL
  • UI round-trip: set, save, reopen, clear; the field follows a reset-to-deployed, and a validation error does not leak across triggers
  • The route editor shows the inherited default, and the CORS badge 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 ["*"]
  • A non-superadmin can read http_route_default_allowed_origins (so the inherited hint renders for the people who did not set it), while license_key stays superadmin-only
  • Workspace-fork clone carries the column
  • cargo check --features http_trigger and --features http_trigger,enterprise,private; 112 unit tests in windmill-trigger-http; npm run check 0 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

hugocasa and others added 3 commits August 25, 2026 14:10
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploying windmill with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8df2b2c
Status: ✅  Deploy successful!
Preview URL: https://d4768778.windmill.pages.dev
Branch Preview URL: https://http-trigger-cors-config.windmill.pages.dev

View logs

hugocasa and others added 3 commits August 25, 2026 15:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: per-route CORS configuration for HTTP triggers

1 participant