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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-25 14:10:43 +02:00
co-authored by Claude Opus 5
parent 0f3d884c6f
commit 27474f9c8c
19 changed files with 935 additions and 50 deletions
@@ -0,0 +1,87 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n http_trigger\n SET\n route_path = $1,\n route_path_key = $2,\n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n allowed_origins = $6,\n authentication_resource_path = $7,\n script_path = $8,\n path = $9,\n is_flow = $10,\n mode = $11,\n http_method = $12,\n static_asset_config = $13,\n edited_by = $14,\n permissioned_as = $15,\n request_type = $16,\n authentication_method = $17,\n summary = $18,\n description = $19,\n edited_at = now(),\n is_static_website = $20,\n error_handler_path = $21,\n error_handler_args = $22,\n retry = $23\n WHERE\n workspace_id = $24 AND\n path = $25\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Bool",
"Bool",
"TextArray",
"Varchar",
"Varchar",
"Varchar",
"Bool",
{
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
},
{
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
},
"Jsonb",
"Varchar",
"Varchar",
{
"Custom": {
"name": "request_type",
"kind": {
"Enum": [
"sync",
"async",
"sync_sse"
]
}
}
},
{
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
},
"Varchar",
"Text",
"Bool",
"Varchar",
"Jsonb",
"Jsonb",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "2ead4c5e0fec64dfdc24431d2f4871ca8667a657dfdfc2fa65e9ff1a3c0d2908"
}
@@ -0,0 +1,84 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n http_trigger\n SET\n wrap_body = $1,\n raw_string = $2,\n allowed_origins = $3,\n authentication_resource_path = $4,\n script_path = $5,\n path = $6,\n is_flow = $7,\n mode = $8,\n http_method = $9,\n static_asset_config = $10,\n edited_by = $11,\n permissioned_as = $12,\n request_type = $13,\n authentication_method = $14,\n summary = $15,\n description = $16,\n edited_at = now(),\n is_static_website = $17,\n error_handler_path = $18,\n error_handler_args = $19,\n retry = $20\n WHERE\n workspace_id = $21 AND\n path = $22\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Bool",
"TextArray",
"Varchar",
"Varchar",
"Varchar",
"Bool",
{
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
},
{
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
},
"Jsonb",
"Varchar",
"Varchar",
{
"Custom": {
"name": "request_type",
"kind": {
"Enum": [
"sync",
"async",
"sync_sse"
]
}
}
},
{
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
},
"Varchar",
"Text",
"Bool",
"Varchar",
"Jsonb",
"Jsonb",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "47e0f46fddb3ad1c854deb9bdbdcbc2bc7235c63ed4f0a885d412793f3a3a3fc"
}
@@ -0,0 +1,86 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO http_trigger (\n workspace_id,\n path,\n route_path,\n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n allowed_origins,\n script_path,\n summary,\n description,\n is_flow,\n mode,\n request_type,\n authentication_method,\n http_method,\n static_asset_config,\n edited_by,\n permissioned_as,\n edited_at,\n is_static_website,\n error_handler_path,\n error_handler_args,\n retry\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, now(), $21, $22, $23, $24\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Bool",
"Bool",
"TextArray",
"Varchar",
"Varchar",
"Text",
"Bool",
{
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
},
{
"Custom": {
"name": "request_type",
"kind": {
"Enum": [
"sync",
"async",
"sync_sse"
]
}
}
},
{
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
},
{
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
},
"Jsonb",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "ab752dd133b20103800554b3f6622e8a9147d36a4b56ebd30a1aaa960156b598"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO http_trigger (\n path, route_path, route_path_key, script_path, is_flow, workspace_id,\n edited_by, edited_at, extra_perms, authentication_method, http_method,\n static_asset_config, is_static_website, workspaced_route, wrap_body,\n raw_string, allowed_origins, authentication_resource_path, summary, description,\n error_handler_path, error_handler_args, retry, request_type, mode,\n permissioned_as, labels\n )\n SELECT\n path, route_path, route_path_key, script_path, is_flow, $1,\n edited_by, edited_at, extra_perms, authentication_method, http_method,\n static_asset_config, is_static_website, workspaced_route, wrap_body,\n raw_string, allowed_origins, authentication_resource_path, summary, description,\n error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE,\n permissioned_as, labels\n FROM http_trigger\n WHERE workspace_id = $2\n AND (workspaced_route IS TRUE OR $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "c1976ac63f5d763b2a747ff92f5ef9db3a0579c889bcae4e8bf33467ce5cebd1"
}
@@ -0,0 +1,185 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n path,\n script_path,\n is_flow,\n route_path,\n authentication_resource_path,\n workspace_id,\n request_type AS \"request_type: _\",\n authentication_method AS \"authentication_method: _\",\n edited_by,\n permissioned_as,\n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n allowed_origins,\n workspaced_route,\n is_static_website,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\",\n mode as \"mode: _\"\n FROM\n http_trigger\n WHERE\n http_method = $1 AND\n (mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "authentication_resource_path",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "request_type: _",
"type_info": {
"Custom": {
"name": "request_type",
"kind": {
"Enum": [
"sync",
"async",
"sync_sse"
]
}
}
}
},
{
"ordinal": 7,
"name": "authentication_method: _",
"type_info": {
"Custom": {
"name": "authentication_method",
"kind": {
"Enum": [
"none",
"windmill",
"api_key",
"basic_http",
"custom_script",
"signature"
]
}
}
}
},
{
"ordinal": 8,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 11,
"name": "wrap_body",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "raw_string",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "allowed_origins",
"type_info": "TextArray"
},
{
"ordinal": 14,
"name": "workspaced_route",
"type_info": "Bool"
},
{
"ordinal": 15,
"name": "is_static_website",
"type_info": "Bool"
},
{
"ordinal": 16,
"name": "error_handler_path",
"type_info": "Varchar"
},
{
"ordinal": 17,
"name": "error_handler_args: _",
"type_info": "Jsonb"
},
{
"ordinal": 18,
"name": "retry: _",
"type_info": "Jsonb"
},
{
"ordinal": 19,
"name": "mode: _",
"type_info": {
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
}
}
],
"parameters": {
"Left": [
{
"Custom": {
"name": "http_method",
"kind": {
"Enum": [
"get",
"post",
"put",
"delete",
"patch"
]
}
}
}
]
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
true,
true,
true,
false
]
},
"hash": "c7a78d3db99e7f709479c9520471eaf40862b464af7113d2632c626e43c35c04"
}
@@ -0,0 +1,2 @@
-- Add down migration script here
ALTER TABLE http_trigger DROP COLUMN allowed_origins;
@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TABLE http_trigger ADD COLUMN allowed_origins TEXT[];
+1 -1
View File
@@ -113,7 +113,7 @@ group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb)
group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char)
FK: (workspace_id, group_name) -> group_(workspace_id, name)
healthchecks: id(bigint), check_type(text), healthy(bool), created_at(ts)
http_trigger: path(char), route_path(char), route_path_key(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), authentication_method(authentication_method), http_method(http_method), static_asset_config(jsonb), is_static_website(bool), workspaced_route(bool), wrap_body(bool), raw_string(bool), authentication_resource_path(char), summary(char), description(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), request_type(request_type), mode(trigger_mode), labels(text[])
http_trigger: path(char), route_path(char), route_path_key(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), authentication_method(authentication_method), http_method(http_method), static_asset_config(jsonb), is_static_website(bool), workspaced_route(bool), wrap_body(bool), raw_string(bool), allowed_origins(text[]), authentication_resource_path(char), summary(char), description(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), request_type(request_type), mode(trigger_mode), labels(text[])
input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_type), name(text), args(jsonb), created_at(ts), created_by(char), is_public(bool)
FK: (workspace_id) -> workspace(id)
instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char)
@@ -5566,7 +5566,7 @@ async fn clone_triggers_and_schedules(
path, route_path, route_path_key, script_path, is_flow, workspace_id,
edited_by, edited_at, extra_perms, authentication_method, http_method,
static_asset_config, is_static_website, workspaced_route, wrap_body,
raw_string, authentication_resource_path, summary, description,
raw_string, allowed_origins, authentication_resource_path, summary, description,
error_handler_path, error_handler_args, retry, request_type, mode,
permissioned_as, labels
)
@@ -5574,7 +5574,7 @@ async fn clone_triggers_and_schedules(
path, route_path, route_path_key, script_path, is_flow, $1,
edited_by, edited_at, extra_perms, authentication_method, http_method,
static_asset_config, is_static_website, workspaced_route, wrap_body,
raw_string, authentication_resource_path, summary, description,
raw_string, allowed_origins, authentication_resource_path, summary, description,
error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE,
permissioned_as, labels
FROM http_trigger
+18
View File
@@ -30196,6 +30196,12 @@ components:
raw_string:
type: boolean
description: If true, passes the request body as a raw string instead of parsing as JSON
allowed_origins:
type: array
nullable: true
items:
type: string
description: "Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. Use ['*'] to allow any origin. When null, the route answers Access-Control-Allow-Origin: * and the runnable's wm_headers can override it; when set, the configured list governs both the preflight and the response."
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
@@ -30285,6 +30291,12 @@ components:
raw_string:
type: boolean
description: If true, passes the request body as a raw string instead of parsing as JSON
allowed_origins:
type: array
nullable: true
items:
type: string
description: "Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. Use ['*'] to allow any origin. When null, the route answers Access-Control-Allow-Origin: * and the runnable's wm_headers can override it; when set, the configured list governs both the preflight and the response."
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
@@ -30381,6 +30393,12 @@ components:
raw_string:
type: boolean
description: If true, passes the request body as a raw string instead of parsing as JSON
allowed_origins:
type: array
nullable: true
items:
type: string
description: "Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. Use ['*'] to allow any origin. When null, the route answers Access-Control-Allow-Origin: * and the runnable's wm_headers can override it; when set, the configured list governs both the preflight and the response."
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
@@ -1,6 +1,6 @@
use super::{
http_trigger_args::RawHttpTriggerArgs, refresh_routers, AuthenticationMethod, HttpMethod,
RequestType, TriggerRoute, HTTP_ACCESS_CACHE, HTTP_AUTH_CACHE, HTTP_ROUTERS_CACHE,
http_trigger_args::RawHttpTriggerArgs, match_origin, refresh_routers, AuthenticationMethod,
HttpMethod, RequestType, TriggerRoute, HTTP_ACCESS_CACHE, HTTP_AUTH_CACHE, HTTP_ROUTERS_CACHE,
};
use crate::{
auth::{AuthCache, OptTokened},
@@ -37,10 +37,64 @@ use {
windmill_object_store::build_object_store_client,
};
/// The route a CORS decision is about, resolved before the request is consumed.
struct CorsRoute {
allowed_origins: Option<Vec<String>>,
http_method: HttpMethod,
}
/// Which router a request's CORS decision must be looked up in.
///
/// A preflight names the method it is asking about in
/// `Access-Control-Request-Method`; the routers are keyed by method, so without
/// that header there is nothing to look up.
fn cors_lookup_method(req: &axum::extract::Request) -> Option<HttpMethod> {
let method = req.method();
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)
} else {
HttpMethod::try_from(method).ok()
}
}
/// Resolve the trigger a request targets, for CORS purposes only.
///
/// Reads the cache without ever refreshing it from the DB:
/// `refresh_routers_loop` keeps it current, and a cold miss falls back to the
/// permissive default rather than putting a query on the CORS path.
async fn resolve_cors_route(http_method: HttpMethod, requested_path: &str) -> Option<CorsRoute> {
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
let trigger = routers_cache
.routers
.get(&http_method)?
.at(requested_path.trim_end_matches('/'))
.ok()?
.value;
Some(CorsRoute { allowed_origins: trigger.allowed_origins.clone(), http_method })
}
async fn conditional_cors_middleware(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let origin = req.headers().get(http::header::ORIGIN).cloned();
// Resolved before `next.run` consumes the request. `&Request` is not `Send`
// (`Body` is not `Sync`), so the lookup takes owned pieces rather than a
// borrow of the request itself.
let lookup = cors_lookup_method(&req).map(|method| (method, req.uri().path().to_string()));
let route = match lookup {
Some((method, path)) => resolve_cors_route(method, &path).await,
None => None,
};
let mut response = next.run(req).await;
let headers = response.headers_mut();
@@ -67,8 +121,28 @@ async fn conditional_cors_middleware(
}
}
// Insert only the missing headers
if !not_insert_origin {
if let Some(allowed_origins) = route
.as_ref()
.and_then(|route| route.allowed_origins.as_ref())
{
// A configured allowlist decides, overriding any `wm_headers` value the
// runnable set. The preflight is answered before any code runs, so
// config is the only thing it can consult; letting the response widen
// what the preflight advertised would make the two disagree and leave
// the allowlist bounding nothing.
match match_origin(allowed_origins, origin.as_ref()) {
Some(value) => headers.insert(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, value),
// No match: omit the header entirely so the browser blocks the
// read, and drop any value the runnable set.
None => headers.remove(http::header::ACCESS_CONTROL_ALLOW_ORIGIN),
};
// Appended, not inserted: the answer now depends on the request's
// Origin, and a shared cache that ignores it would hand one origin's
// response to another.
if !allowed_origins.iter().any(|allowed| allowed == "*") {
headers.append(http::header::VARY, http::HeaderValue::from_static("origin"));
}
} else if !not_insert_origin {
headers.insert(
http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
http::HeaderValue::from_static("*"),
@@ -76,9 +150,18 @@ async fn conditional_cors_middleware(
}
if !not_insert_methods {
// A resolved route accepts exactly one method, so advertising all seven
// overstates it. Unresolved requests keep the historical list.
headers.insert(
http::header::ACCESS_CONTROL_ALLOW_METHODS,
http::HeaderValue::from_static("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
http::HeaderValue::from_static(match route.as_ref().map(|route| route.http_method) {
Some(HttpMethod::Get) => "GET, OPTIONS",
Some(HttpMethod::Post) => "POST, OPTIONS",
Some(HttpMethod::Put) => "PUT, OPTIONS",
Some(HttpMethod::Delete) => "DELETE, OPTIONS",
Some(HttpMethod::Patch) => "PATCH, OPTIONS",
None => "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS",
}),
);
}
+52 -41
View File
@@ -1,6 +1,6 @@
use super::{
validate_authentication_method, HttpConfig, HttpConfigRequest, HttpMethod, HttpTrigger,
RouteExists, ROUTE_PATH_KEY_RE, VALID_ROUTE_PATH_RE,
validate_allowed_origins, validate_authentication_method, HttpConfig, HttpConfigRequest,
HttpMethod, HttpTrigger, RouteExists, ROUTE_PATH_KEY_RE, VALID_ROUTE_PATH_RE,
};
use async_trait::async_trait;
use axum::{extract::Path, routing::post, Extension, Json, Router};
@@ -189,6 +189,7 @@ pub async fn insert_new_trigger_into_db(
authentication_resource_path,
wrap_body,
raw_string,
allowed_origins,
script_path,
summary,
description,
@@ -207,7 +208,7 @@ pub async fn insert_new_trigger_into_db(
retry
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, now(), $20, $21, $22, $23
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, now(), $21, $22, $23, $24
)
"#,
w_id,
@@ -218,6 +219,7 @@ pub async fn insert_new_trigger_into_db(
trigger.config.authentication_resource_path,
trigger.config.wrap_body.unwrap_or(false),
trigger.config.raw_string.unwrap_or(false),
trigger.config.allowed_origins.as_deref(),
trigger.base.script_path,
trigger.config.summary,
trigger.config.description,
@@ -435,6 +437,7 @@ impl TriggerCrud for HttpTrigger {
"workspaced_route",
"wrap_body",
"raw_string",
"allowed_origins",
];
fn get_deployed_object(path: String, parent_path: Option<String>) -> DeployedObject {
@@ -465,6 +468,8 @@ impl TriggerCrud for HttpTrigger {
validate_authentication_method(new.authentication_method, new.raw_string)?;
validate_allowed_origins(new.allowed_origins.as_ref())?;
Ok(())
}
@@ -483,6 +488,8 @@ impl TriggerCrud for HttpTrigger {
validate_authentication_method(edit.authentication_method, edit.raw_string)?;
validate_allowed_origins(edit.allowed_origins.as_ref())?;
Ok(())
}
@@ -545,33 +552,35 @@ impl TriggerCrud for HttpTrigger {
workspaced_route = $3,
wrap_body = $4,
raw_string = $5,
authentication_resource_path = $6,
script_path = $7,
path = $8,
is_flow = $9,
mode = $10,
http_method = $11,
static_asset_config = $12,
edited_by = $13,
permissioned_as = $14,
request_type = $15,
authentication_method = $16,
summary = $17,
description = $18,
allowed_origins = $6,
authentication_resource_path = $7,
script_path = $8,
path = $9,
is_flow = $10,
mode = $11,
http_method = $12,
static_asset_config = $13,
edited_by = $14,
permissioned_as = $15,
request_type = $16,
authentication_method = $17,
summary = $18,
description = $19,
edited_at = now(),
is_static_website = $19,
error_handler_path = $20,
error_handler_args = $21,
retry = $22
is_static_website = $20,
error_handler_path = $21,
error_handler_args = $22,
retry = $23
WHERE
workspace_id = $23 AND
path = $24
workspace_id = $24 AND
path = $25
"#,
route_path,
&route_path_key,
Some(effective_workspaced),
trigger.config.wrap_body,
trigger.config.raw_string,
trigger.config.allowed_origins.as_deref(),
trigger.config.authentication_resource_path,
trigger.base.script_path,
trigger.base.path,
@@ -604,30 +613,32 @@ impl TriggerCrud for HttpTrigger {
SET
wrap_body = $1,
raw_string = $2,
authentication_resource_path = $3,
script_path = $4,
path = $5,
is_flow = $6,
mode = $7,
http_method = $8,
static_asset_config = $9,
edited_by = $10,
permissioned_as = $11,
request_type = $12,
authentication_method = $13,
summary = $14,
description = $15,
allowed_origins = $3,
authentication_resource_path = $4,
script_path = $5,
path = $6,
is_flow = $7,
mode = $8,
http_method = $9,
static_asset_config = $10,
edited_by = $11,
permissioned_as = $12,
request_type = $13,
authentication_method = $14,
summary = $15,
description = $16,
edited_at = now(),
is_static_website = $16,
error_handler_path = $17,
error_handler_args = $18,
retry = $19
is_static_website = $17,
error_handler_path = $18,
error_handler_args = $19,
retry = $20
WHERE
workspace_id = $20 AND
path = $21
workspace_id = $21 AND
path = $22
"#,
trigger.config.wrap_body,
trigger.config.raw_string,
trigger.config.allowed_origins.as_deref(),
trigger.config.authentication_resource_path,
trigger.base.script_path,
trigger.base.path,
+193 -1
View File
@@ -47,6 +47,7 @@ pub struct TriggerRoute {
pub workspaced_route: bool,
pub wrap_body: bool,
pub raw_string: bool,
pub allowed_origins: Option<Vec<String>>,
pub error_handler_path: Option<String>,
pub error_handler_args: Option<sqlx::types::Json<HashMap<String, serde_json::Value>>>,
pub retry: Option<sqlx::types::Json<Retry>>,
@@ -119,6 +120,7 @@ pub struct HttpConfig {
pub workspaced_route: bool,
pub wrap_body: bool,
pub raw_string: bool,
pub allowed_origins: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
@@ -136,6 +138,7 @@ pub struct HttpConfigRequest {
pub workspaced_route: Option<bool>,
pub wrap_body: Option<bool>,
pub raw_string: Option<bool>,
pub allowed_origins: Option<Vec<String>>,
}
#[derive(Deserialize)]
@@ -154,6 +157,7 @@ struct HttpConfigRequestHelper {
workspaced_route: Option<bool>,
wrap_body: Option<bool>,
raw_string: Option<bool>,
allowed_origins: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for HttpConfigRequest {
@@ -189,6 +193,7 @@ impl<'de> Deserialize<'de> for HttpConfigRequest {
workspaced_route: helper.workspaced_route,
wrap_body: helper.wrap_body,
raw_string: helper.raw_string,
allowed_origins: normalize_allowed_origins(helper.allowed_origins),
})
}
}
@@ -208,6 +213,95 @@ pub struct RouteExists {
pub workspaced_route: Option<bool>,
}
/// Collapse an empty list to `None` so "no allowlist configured" has a single
/// representation. `NULL` is the only value that means "keep the historical
/// `Access-Control-Allow-Origin: *`", and an empty array reaching the column
/// would be a second, silently different one.
pub fn normalize_allowed_origins(allowed_origins: Option<Vec<String>>) -> Option<Vec<String>> {
allowed_origins.filter(|origins| !origins.is_empty())
}
/// Resolve the `Access-Control-Allow-Origin` value for a request, or `None` to
/// omit the header so the browser blocks the read.
///
/// The request's `Origin` is echoed back only on a match against the allowlist.
/// Reflecting it unchecked is the classic way this feature turns into no
/// restriction at all.
///
/// The comparison ignores ASCII case because a browser lowercases the scheme and
/// host it sends, so a configured `https://App.Example.com` would otherwise name
/// a real origin and still match nothing.
pub fn match_origin(
allowed_origins: &[String],
origin: Option<&http::HeaderValue>,
) -> Option<http::HeaderValue> {
if allowed_origins.iter().any(|allowed| allowed == "*") {
return Some(http::HeaderValue::from_static("*"));
}
let origin = origin?;
let origin_str = origin.to_str().ok()?;
allowed_origins
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(origin_str))
.then(|| origin.clone())
}
/// Reject allowlist entries a browser would silently ignore.
///
/// An origin is a bare `scheme://host[:port]`: anything with a path, query,
/// fragment or userinfo never equals the `Origin` header a browser sends, so it
/// would look configured while matching nothing. `null` is rejected outright —
/// every sandboxed iframe sends `Origin: null`, so allowing it grants access to
/// any page that can open one.
pub fn validate_allowed_origins(allowed_origins: Option<&Vec<String>>) -> Result<()> {
let Some(allowed_origins) = allowed_origins else {
return Ok(());
};
for origin in allowed_origins {
if origin == "*" {
continue;
}
let invalid = |reason: &str| {
Error::BadRequest(format!(
"Invalid allowed origin '{}': {}. Expected an origin such as https://app.example.com, or * to allow any origin.",
origin, reason
))
};
if http::HeaderValue::from_str(origin).is_err() {
return Err(invalid("not a valid header value"));
}
let Some((scheme, rest)) = origin.split_once("://") else {
return Err(invalid("missing scheme"));
};
if scheme.is_empty()
|| !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '+' || c == '-')
{
return Err(invalid("invalid scheme"));
}
if rest.is_empty() {
return Err(invalid("missing host"));
}
if rest.contains('/') {
return Err(invalid("must not contain a path or trailing slash"));
}
if rest.contains('?') || rest.contains('#') {
return Err(invalid("must not contain a query or fragment"));
}
if rest.contains('@') {
return Err(invalid("must not contain userinfo"));
}
}
Ok(())
}
pub fn validate_authentication_method(
authentication_method: AuthenticationMethod,
raw_string: Option<bool>,
@@ -256,6 +350,7 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route
static_asset_config AS "static_asset_config: _",
wrap_body,
raw_string,
allowed_origins,
workspaced_route,
is_static_website,
error_handler_path,
@@ -274,7 +369,8 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route
.await?;
let mut router = matchit::Router::new();
let http_route_workspaced = HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
let http_route_workspaced =
HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
for trigger in triggers {
let full_path =
@@ -549,6 +645,102 @@ mod tests {
assert!(validate_authentication_method(AuthenticationMethod::Signature, None).is_ok());
}
// --- CORS allowed origins ---
fn origin(value: &str) -> http::HeaderValue {
http::HeaderValue::from_str(value).unwrap()
}
#[test]
fn test_match_origin_exact_match_echoes_request_origin() {
let allowed = vec!["https://a.com".to_string(), "https://b.com".to_string()];
assert_eq!(
match_origin(&allowed, Some(&origin("https://b.com"))),
Some(origin("https://b.com"))
);
}
#[test]
fn test_match_origin_ignores_case() {
let allowed = vec!["https://App.Example.com".to_string()];
assert_eq!(
match_origin(&allowed, Some(&origin("https://app.example.com"))),
Some(origin("https://app.example.com"))
);
}
#[test]
fn test_match_origin_no_match_omits_header() {
let allowed = vec!["https://a.com".to_string()];
assert_eq!(
match_origin(&allowed, Some(&origin("https://evil.com"))),
None
);
// A prefix of an allowed origin must not match: https://a.com.evil.com
// is a different site entirely.
assert_eq!(
match_origin(&allowed, Some(&origin("https://a.com.evil.com"))),
None
);
}
#[test]
fn test_match_origin_wildcard_allows_any() {
let allowed = vec!["*".to_string()];
assert_eq!(
match_origin(&allowed, Some(&origin("https://evil.com"))),
Some(origin("*"))
);
// `*` holds even with no Origin header, matching the historical default.
assert_eq!(match_origin(&allowed, None), Some(origin("*")));
}
#[test]
fn test_match_origin_missing_origin_header_omits_header() {
let allowed = vec!["https://a.com".to_string()];
assert_eq!(match_origin(&allowed, None), None);
}
#[test]
fn test_validate_allowed_origins_accepts_origins_and_wildcard() {
let allowed = vec![
"https://app.example.com".to_string(),
"http://localhost:3000".to_string(),
"*".to_string(),
];
assert!(validate_allowed_origins(Some(&allowed)).is_ok());
assert!(validate_allowed_origins(None).is_ok());
}
#[test]
fn test_validate_allowed_origins_rejects_non_origins() {
for invalid in [
"https://app.example.com/",
"https://app.example.com/path",
"https://user@app.example.com",
"https://app.example.com?a=b",
"app.example.com",
// Every sandboxed iframe sends `Origin: null`, so allowing it would
// grant access to any page that can open one.
"null",
] {
assert!(
validate_allowed_origins(Some(&vec![invalid.to_string()])).is_err(),
"expected {invalid} to be rejected"
);
}
}
#[test]
fn test_normalize_allowed_origins_collapses_empty_to_none() {
assert_eq!(normalize_allowed_origins(Some(vec![])), None);
assert_eq!(normalize_allowed_origins(None), None);
assert_eq!(
normalize_allowed_origins(Some(vec!["*".to_string()])),
Some(vec!["*".to_string()])
);
}
// --- Route path regex ---
#[test]
+9
View File
@@ -8363,6 +8363,15 @@ properties:
type: boolean
description: If true, passes the request body as a raw string instead of parsing
as JSON
allowed_origins:
type: array
items:
type: string
description: 'Origins allowed to call this route cross-origin, matched against
the request''s Origin header (ignoring case) and echoed back on a match. Use
[''*''] to allow any origin. When null, the route answers Access-Control-Allow-Origin:
* and the runnable''s wm_headers can override it; when set, the configured list
governs both the preflight and the response.'
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
@@ -69,6 +69,7 @@ export const httpTriggerRequestSchema = z.object({
"wrap_body": z.boolean().describe("If true, wraps the request body in a 'body' parameter").optional(),
"mode": z.enum(["enabled", "disabled", "suspended"]).describe("job trigger mode").optional(),
"raw_string": z.boolean().describe("If true, passes the request body as a raw string instead of parsing as JSON").optional(),
"allowed_origins": z.array(z.string()).describe("Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. Use ['*'] to allow any origin. When null, the route answers Access-Control-Allow-Origin: * and the runnable's wm_headers can override it; when set, the configured list governs both the preflight and the response.").nullable().optional(),
"error_handler_path": z.string().describe("Path to a script or flow to run when the triggered job fails").optional(),
"error_handler_args": z.record(z.string(), z.any()).describe("Arguments to pass to the error handler").optional(),
"retry": z.object({
@@ -0,0 +1,92 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import type { Snippet } from 'svelte'
interface Props {
allowed_origins: string[] | undefined
disabled?: boolean
testingBadge?: Snippet | undefined
}
let {
allowed_origins = $bindable(),
disabled = false,
testingBadge = undefined
}: Props = $props()
// The text field is the editing surface, `allowed_origins` the saved value.
// Keeping them separate lets a half-typed entry stay on screen while the
// trigger config holds only what parses.
let raw = $state(allowed_origins?.join(', ') ?? '')
let restricted = $state(allowed_origins !== undefined)
function parse(value: string): string[] {
return value
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin !== '')
}
// 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
}
let origins = $derived(parse(raw))
let error = $derived(origins.map(originError).find((error) => error !== undefined))
$effect(() => {
allowed_origins = restricted && !error && origins.length > 0 ? origins : undefined
})
</script>
<Label label="Restrict origins" for="allowed-origins-toggle" class="w-full">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing">
Lists the origins allowed to call this route from a browser. Windmill answers the preflight
and the response with the origin that matches, so a page on any other origin cannot read the
result. Requests from outside a browser are unaffected. Leave off to keep the route readable
from any origin.
</Tooltip>
{#if testingBadge}
{@render testingBadge()}
{/if}
{/snippet}
{#snippet action()}
<Toggle
checked={restricted}
on:change={() => {
restricted = !restricted
}}
{disabled}
id="allowed-origins-toggle"
/>
{/snippet}
{#if restricted}
<TextInput
bind:value={raw}
inputProps={{ autocomplete: 'off', disabled, placeholder: 'https://app.example.com' }}
error={error !== undefined}
/>
<div class="text-2xs text-secondary">
Separate origins with commas. Use * to allow any origin.
</div>
{#if error}
<div class="text-2xs text-red-600 dark:text-red-400">{error}</div>
{/if}
{/if}
</Label>
@@ -49,6 +49,7 @@
import { HUB_SCRIPT_ID, saveHttpRouteFromCfg, SECRET_KEY_PATH } from './utils'
import { HubFlow } from '$lib/hub'
import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte'
import RouteCorsOption from './RouteCorsOption.svelte'
import TestingBadge from '../testingBadge.svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
@@ -110,6 +111,7 @@
let workspaced_route = $state(false)
let raw_string = $state(false)
let wrap_body = $state(false)
let allowed_origins = $state<string[] | undefined>(undefined)
let drawerLoading = $state(true)
let showLoader = $state(false)
let authentication_resource_path = $state('')
@@ -295,6 +297,7 @@
signature_options_type = defaultValues?.signature_options_type ?? 'custom_signature'
raw_string = defaultValues?.raw_string ?? false
wrap_body = defaultValues?.wrap_body ?? false
allowed_origins = defaultValues?.allowed_origins ?? undefined
summary = defaultValues?.summary ?? ''
routeDescription = defaultValues?.description ?? ''
error_handler_path = defaultValues?.error_handler_path ?? undefined
@@ -323,6 +326,7 @@
workspaced_route = cfg?.workspaced_route ?? false
wrap_body = cfg?.wrap_body ?? false
raw_string = cfg?.raw_string ?? false
allowed_origins = cfg?.allowed_origins ?? undefined
summary = cfg?.summary ?? ''
mode = cfg?.mode ?? 'enabled'
routeDescription = cfg?.description ?? ''
@@ -423,6 +427,7 @@
mode,
wrap_body,
raw_string,
allowed_origins,
authentication_resource_path,
authentication_method: auth_method,
static_asset_config,
@@ -961,6 +966,8 @@
disabled={!can_write}
{testingBadge}
/>
<RouteCorsOption bind:allowed_origins disabled={!can_write} {testingBadge} />
</div>
{:else}
<TriggerRetriesAndErrorHandler
@@ -56,6 +56,7 @@ export async function saveHttpRouteFromCfg(
authentication_resource_path: routeCfg.authentication_resource_path,
wrap_body: routeCfg.wrap_body,
raw_string: routeCfg.raw_string,
allowed_origins: routeCfg.allowed_origins,
description: routeCfg.description,
summary: routeCfg.summary,
error_handler_path: routeCfg.error_handler_path,
@@ -95,6 +95,15 @@ properties:
type: boolean
description: If true, passes the request body as a raw string instead of parsing
as JSON
allowed_origins:
type: array
items:
type: string
description: 'Origins allowed to call this route cross-origin, matched against
the request''s Origin header (ignoring case) and echoed back on a match. Use
[''*''] to allow any origin. When null, the route answers Access-Control-Allow-Origin:
* and the runnable''s wm_headers can override it; when set, the configured list
governs both the preflight and the response.'
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails