mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: add per-route CORS origin allowlist for HTTP triggers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0f3d884c6f
commit
27474f9c8c
+87
@@ -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"
|
||||
}
|
||||
+84
@@ -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"
|
||||
}
|
||||
+86
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+185
@@ -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[];
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user