Files
windmill/backend/windmill-common/src/global_settings.rs
T
hugocasaandClaude Opus 5 37e493ae66 feat: add an instance setting to refuse a token in MCP URLs (#11162)
* feat: add an instance setting to refuse a token in MCP URLs

MCP clients are commonly configured with the token in the URL
(`/api/mcp/w/{workspace}/mcp?token=...`). A URL-borne credential ends up in
browser history, proxy logs and referrers, so an instance can now turn that
channel off with the `mcp_disable_token_query_param` global setting and leave
the Authorization header as the only way in, which sends MCP clients through
the OAuth flow the endpoints already advertise.

The rejection is a middleware on both the workspaced and the gateway MCP
mounts, layered outside everything that reads a token and inside the
WWW-Authenticate layer, so the 401 carries the resource pointer a client needs
to start OAuth discovery.

Off by default. With it on, the token drawer and the home connect drawer stop
offering to mint a token for an MCP URL and hand over the bare URL instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read the MCP URL policy when a URL is asked for, and drop the all-workspaces option

Two review findings on the token drawer:

The policy was read once per page load and cached for the browser session, so a
superadmin turning the setting on left every open tab handing out `?token=` URLs
the server now refuses. Both entry points now read it when the user actually asks
for an MCP URL: when MCP mode is entered, and when the connect drawer opens.

The workspace picker offered "All workspaces / Multi-workspace", but the gateway's
consent screen binds the token it issues to the one workspace picked there, so
OAuth has no multi-workspace grant to hand out. That entry is now token-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: don't guess the MCP URL policy, and say where the switch lands on restart

Review findings:

The comment on the settings load claimed `MODE=mcp` as the target deployment, but
that mode joins no monitor loop, so the startup pass is its only read and a change
lands on restart. That is true of every global setting there, `base_url` included;
the comment now says so, and the setting description tells an operator running
dedicated MCP servers what to expect.

A failed settings probe resolved to "tokens allowed", so with the switch on the
drawer would mint a non-expiring token and hand over a URL the server refuses for
as long as it exists. The probe now propagates its error and the panel reports it
with a retry, creating nothing until the answer is known.

The test passed a valid token, so it could not tell a rejection before
authentication from one after it. It now also sends a token that was never valid
and asserts the middleware's own message, which fails if the layer moves inward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: withhold the MCP URL until a workspace is picked

With no persisted workspace the store starts undefined, so opening the drawer from
/user/workspaces before choosing one rendered a copyable
`/api/mcp/w/undefined/mcp`. It reads like a real URL and a client pointed at it
would never connect. The panel now asks for a workspace instead, matching the guard
the token branch already has on its generate button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: drop the coverage-status note from the MCP switch test

It documented what the test does not reach rather than a constraint the next
reader could break; that belongs in the PR, not the module doc. The layer-order
rationale, which is what a future edit would break, stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: fall back to the bare MCP URL instead of alerting on a failed read

When the setting read fails, show the bare URL rather than an error with a retry.
It works whichever way the setting is, so no alert is needed, and it still never
mints a token for a URL the server may refuse. The connect drawer's wording falls
back the same way so the blurb matches the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: move the MCP URL token setting to Core

It sat in the Auth/OAuth/SAML list, which the settings sidebar shows under SSO,
suggesting a dependency on SSO that does not exist: MCP OAuth has Windmill act as
the authorization server, and any login method, password included, completes it.
It is an instance-wide credential policy, so it now lives with the other ones in
Core, kept out of quick setup like its neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:24:00 +02:00

1107 lines
48 KiB
Rust

// Adding a global setting? Decide whether agent workers may read it. Agent
// workers (remote workers connected over HTTP) fetch settings through an
// endpoint that is deny-by-exception: every key is served except those in
// AGENT_WORKER_BLOCKED_SETTINGS (defined below). If a new setting holds an
// instance secret the server should keep to itself, add its key there.
pub const CUSTOM_TAGS_SETTING: &str = "custom_tags";
pub const DEFAULT_TAGS_PER_WORKSPACE_SETTING: &str = "default_tags_per_workspace";
pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
pub const FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING: &str =
"fork_workspace_tag_append_fork_suffix";
pub const PREVIEW_TAGS_OVERRIDE_SETTING: &str = "preview_tags_override";
pub const BASE_URL_SETTING: &str = "base_url";
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
pub const OAUTH_SETTING: &str = "oauths";
pub const AI_CONFIG_SETTING: &str = "ai_config";
pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs";
pub const RETENTION_PERIOD_SECS_OVERRIDES_SETTING: &str = "retention_period_secs_overrides";
pub const SERVICE_LOG_RETENTION_SECS_SETTING: &str = "service_log_retention_secs";
/// Upper bound on how many per-workspace retention overrides may be configured. The periodic monitor
/// sweeps each override workspace in its own transaction every pass, so this keeps a pass bounded
/// (and the feature is a targeted escape hatch for a handful of special workspaces, not a bulk knob).
/// Enforced at write time and defensively on load.
pub const MAX_RETENTION_OVERRIDE_WORKSPACES: usize = 10;
pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days";
pub const STORE_AUDIT_LOGS_S3_SETTING: &str = "store_audit_logs_s3";
/// `background_task_state.name` for the audit-log → object-store export cursor.
/// NOT a global setting — runtime task state lives in `background_task_state`
/// (see the migration `audit_logs_s3_anchor_on_enable`), so it is never part
/// of instance config / config sync. Keep in sync with the trigger SQL literal.
pub const AUDIT_LOGS_S3_EXPORT_TASK: &str = "audit_logs_s3_export";
pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3";
pub const JOB_DEFAULT_TIMEOUT_SECS_SETTING: &str = "job_default_timeout";
pub const REQUEST_SIZE_LIMIT_SETTING: &str = "request_size_limit_mb";
pub const LICENSE_KEY_SETTING: &str = "license_key";
pub const NPM_CONFIG_REGISTRY_SETTING: &str = "npm_config_registry";
pub const BUNFIG_INSTALL_SCOPES_SETTING: &str = "bunfig_install_scopes";
pub const NPMRC_SETTING: &str = "npmrc";
pub const NUGET_CONFIG_SETTING: &str = "nuget_config";
pub const POWERSHELL_REPO_URL_SETTING: &str = "powershell_repo_url";
pub const POWERSHELL_REPO_PAT_SETTING: &str = "powershell_repo_pat";
pub const MAVEN_REPOS_SETTING: &str = "maven_repos";
pub const MAVEN_SETTINGS_XML_SETTING: &str = "maven_settings_xml";
pub const NO_DEFAULT_MAVEN_SETTING: &str = "no_default_maven";
pub const RUBY_REPOS_SETTING: &str = "ruby_repos";
pub const CARGO_REGISTRIES_SETTING: &str = "cargo_registries";
pub const EXTRA_PIP_INDEX_URL_SETTING: &str = "pip_extra_index_url";
pub const PIP_INDEX_URL_SETTING: &str = "pip_index_url";
pub const UV_INDEX_STRATEGY_SETTING: &str = "uv_index_strategy";
pub const UV_EXCLUDE_NEWER_SETTING: &str = "uv_exclude_newer";
pub const UV_PYTHON_INSTALL_MIRROR_SETTING: &str = "uv_python_install_mirror";
pub const BUN_INSTALL_MIN_RELEASE_AGE_SETTING: &str = "bun_install_min_release_age";
pub const INSTANCE_PYTHON_VERSION_SETTING: &str = "instance_python_version";
pub const RUFF_CONFIG_SETTING: &str = "ruff_config";
pub const SCIM_TOKEN_SETTING: &str = "scim_token";
pub const SAML_METADATA_SETTING: &str = "saml_metadata";
pub const SMTP_SETTING: &str = "smtp_settings";
pub const TEAMS_SETTING: &str = "teams";
pub const INDEXER_SETTING: &str = "indexer_settings";
pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result";
pub const UNIQUE_ID_SETTING: &str = "uid";
pub const DISABLE_STATS_SETTING: &str = "disable_stats";
pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics";
pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics";
pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir";
pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth";
/// Superadmin switch over guest sessions for the whole instance, above the per-workspace
/// one. Read from the table, uncached, by the same gates that read the workspace switch;
/// the superadmin Guests list writes it through `/settings/global/{key}` by this name.
pub const GUEST_ACCESS_DISABLED_SETTING: &str = "guest_access_disabled";
pub const JOB_ISOLATION_SETTING: &str = "job_isolation";
pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
pub const NSJAIL_TMP_BACKING_DISK: &str = "disk";
pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs";
pub const SANDBOX_IMAGE_MAX_SIZE_MB_SETTING: &str = "sandbox_image_max_size_mb";
pub const SANDBOX_IMAGE_CACHE_MAX_MB_SETTING: &str = "sandbox_image_cache_max_mb";
pub const SANDBOX_IMAGE_PULL_POLICY_SETTING: &str = "sandbox_image_pull_policy";
pub const SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING: &str = "sandbox_image_default_registry";
pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth";
// Enables the `#ssh <resource>` directive that reroutes bash execution to a
// remote host over SSH (enterprise feature). Off by default. See
// windmill-worker/src/ssh_executor_ee.rs.
pub const SSH_EXECUTION_SETTING: &str = "ssh_execution_enabled";
pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config";
/// Whether the instance object store stands in for a workspace without storage of its own
/// as the place its members' AI sessions are backed up to. On unless the row says `false`;
/// inert without an instance object store.
pub const AI_SESSIONS_INSTANCE_STORAGE_FALLBACK_SETTING: &str =
"ai_sessions_instance_storage_fallback";
/// Compile a newly deployed script's binary right after its dependency job and push it
/// to the instance object store, so the first run does not pay the compile. Inert unless
/// instance object storage is configured — without it the binary would only ever land in
/// the building worker's local cache, which is not where the next run looks.
pub const AUTO_BUILD_BINARY_ON_DEPLOY_SETTING: &str = "auto_build_binary_on_deploy";
/// Worker tag the auto-build jobs are queued on. Unset means the script's language tag,
/// which is where its dependency job already runs.
pub const AUTO_BUILD_BINARY_TAG_SETTING: &str = "auto_build_binary_tag";
pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way
/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an
/// instance that cares sends MCP clients through the OAuth flow instead.
pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param";
/// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or
/// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no
/// expiration at all, is shortened to it rather than refused. On those routes only: server-side
/// mints (webhook tokens, app embed tokens, sessions) choose a lifetime the caller never picks
/// and go straight to `create_token_internal`. Read and validated by
/// [`parse_max_token_expiration_days`].
pub const MAX_TOKEN_EXPIRATION_DAYS_SETTING: &str = "max_token_expiration_days";
/// Largest `max_token_expiration_days` read as a ceiling, about 2,700 years. The token form
/// applies the same bound (`frontend/src/lib/tokenExpiration.ts`) so that it and the server
/// agree on whether a ceiling exists.
pub const MAX_TOKEN_EXPIRATION_DAYS_BOUND: i64 = 1_000_000;
/// Reads a stored `max_token_expiration_days`: `Ok(None)` when unset or cleared (null or an
/// empty string), the ceiling for a whole number of days within
/// `1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND` stored as an integer, an integral float or a string of
/// digits, and an error for anything else.
///
/// The settings API and config sync both reject the error at write time: the token routes can
/// only read an unparseable value as no ceiling, so accepting a typo would silently turn the
/// policy off. `parseMaxTokenExpirationDays` in the frontend must accept exactly the same values.
pub fn parse_max_token_expiration_days(
value: Option<&serde_json::Value>,
) -> Result<Option<i64>, String> {
let days = match value {
None | Some(serde_json::Value::Null) => return Ok(None),
Some(serde_json::Value::String(s)) if s.trim().is_empty() => return Ok(None),
Some(serde_json::Value::Number(n)) => n
.as_i64()
.or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
Some(serde_json::Value::String(s)) => s.trim().parse::<i64>().ok(),
Some(_) => None,
};
match days {
Some(days) if (1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND).contains(&days) => Ok(Some(days)),
_ => Err(format!(
"must be a whole number of days from 1 to {MAX_TOKEN_EXPIRATION_DAYS_BOUND}, or empty for no limit"
)),
}
}
pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider";
/// Name of the SAML attribute or OIDC userinfo claim carrying the user's IdP groups. Unset or
/// empty leaves instance-group membership entirely to SCIM.
pub const SSO_GROUPS_CLAIM_SETTING: &str = "sso_groups_claim";
pub const HUB_BASE_URL_SETTING: &str = "hub_base_url";
pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url";
pub const DISABLE_HUB_SETTING: &str = "disable_hub";
pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels";
pub const CRITICAL_ALERT_MUTE_UI_SETTING: &str = "critical_alert_mute_ui";
pub const CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING: &str = "critical_alerts_on_db_oversize";
pub const CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING: &str = "critical_alerts_on_token_expiry";
pub const CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING: &str =
"critical_alert_mute_zombie_job_restart";
pub const DEV_INSTANCE_SETTING: &str = "dev_instance";
pub const JWT_SECRET_SETTING: &str = "jwt_secret";
pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";
pub const OTEL_SETTING: &str = "otel";
pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy";
pub const OTEL_TRACES_RETENTION_SECS_SETTING: &str = "otel_traces_retention_secs";
pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route";
pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_workspaced_route";
pub const HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING: &str = "http_route_default_allowed_origins";
pub const SECRET_BACKEND_SETTING: &str = "secret_backend";
pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version";
pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app";
/// Base URL GitHub delivers git-sync repository webhooks to. Falls back to
/// `base_url` when unset; set it when the browser-facing URL is not reachable
/// from GitHub and a separate ingress fronts the API for inbound webhooks.
pub const GITHUB_APP_WEBHOOK_BASE_URL_SETTING: &str = "github_app_webhook_base_url";
/// Instance-wide announcement rendered above every page of the app (maintenance
/// windows, incidents). Readable by any authenticated user, unlike most settings:
/// the banner exists to be shown to everyone, so it must never hold anything the
/// whole instance may not see.
pub const INSTANCE_BANNER_SETTING: &str = "instance_banner";
/// Ceiling on the banner message. The banner is a one-or-two-line strip above every
/// page, so anything longer is a layout accident rather than an announcement.
pub const INSTANCE_BANNER_MESSAGE_MAX_LEN: usize = 500;
/// Ceiling on the banner's link label, which renders as a button inside that same strip.
pub const INSTANCE_BANNER_LINK_LABEL_MAX_LEN: usize = 60;
/// Validate an [`INSTANCE_BANNER_SETTING`] value.
///
/// The banner is the one setting rendered to every user of the instance, so its
/// shape is checked at the boundary rather than trusted from the writer: a value
/// that reaches the browser malformed breaks the layout for everyone at once.
///
/// The link is restricted to http(s) so a stored `javascript:`/`data:` URL can
/// never become the href of an anchor every user sees.
///
/// Only shapes that would *misrender* are rejected. An enabled banner with no message
/// is left alone deliberately: it renders as nothing, and every write path here runs
/// under the bulk settings save, so rejecting it would fail an admin's whole settings
/// edit — retention, SMTP and all — over a half-typed announcement.
pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> {
let obj = value
.as_object()
.ok_or_else(|| "must be a JSON object".to_string())?;
// Field types are checked before their contents. Every read below is an `as_str`/
// `as_bool`, which reports a wrong-typed field as absent — so without this a
// `"link": 123` would skip the URL checks entirely and be stored, and the settings
// form would then throw on it (`link.trim()` on a number) instead of rendering.
for (field, expected, ok) in [
(
"enabled",
"a boolean",
obj.get("enabled").is_none_or(|v| v.is_boolean()),
),
(
"dismissible",
"a boolean",
obj.get("dismissible").is_none_or(|v| v.is_boolean()),
),
(
"message",
"a string",
obj.get("message").is_none_or(|v| v.is_string()),
),
(
"severity",
"a string",
obj.get("severity").is_none_or(|v| v.is_string()),
),
(
"link",
"a string",
obj.get("link").is_none_or(|v| v.is_string()),
),
(
"link_label",
"a string",
obj.get("link_label").is_none_or(|v| v.is_string()),
),
] {
if !ok {
return Err(format!("{field} must be {expected}"));
}
}
for (field, max) in [
("message", INSTANCE_BANNER_MESSAGE_MAX_LEN),
("link_label", INSTANCE_BANNER_LINK_LABEL_MAX_LEN),
] {
let len = obj
.get(field)
.and_then(|v| v.as_str())
.map_or(0, |s| s.chars().count());
if len > max {
return Err(format!("{field} must be at most {max} characters"));
}
}
if let Some(severity) = obj.get("severity").and_then(|v| v.as_str()) {
if !matches!(severity, "info" | "warning" | "error") {
return Err("severity must be one of info, warning, error".to_string());
}
}
if let Some(link) = obj.get("link").and_then(|v| v.as_str()) {
if !link.trim().is_empty() {
let url = url::Url::parse(link.trim())
.map_err(|e| format!("link must be an absolute http(s) URL: {e}"))?;
if !matches!(url.scheme(), "http" | "https") {
return Err("link must use the http or https scheme".to_string());
}
if !url.has_host() {
return Err("link must include a host".to_string());
}
}
}
Ok(())
}
/// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value.
///
/// The receiver path is appended to it verbatim, so anything that doesn't
/// concatenate into a URL GitHub can POST to must be rejected at write time
/// rather than silently producing an unreachable hook: a wrong scheme
/// (`httpss://`), a missing host, embedded whitespace, or a query/fragment
/// (appending a path after `?`/`#` keeps it inside the query/fragment).
///
/// No message here echoes the submitted value. Userinfo is not the only secret a
/// URL can carry — `?token=…` is just as common — so rather than enumerating the
/// shapes worth hiding, no branch reports the value at all and each says what was
/// wrong with it instead. That matters because these strings reach further than the
/// submitter: the declarative path wraps them into `sync-config` output and operator
/// reconcile logs.
pub fn validate_webhook_base_url(value: &str) -> Result<(), String> {
let value = value.trim();
let url =
url::Url::parse(value).map_err(|e| format!("must be an absolute http(s) URL: {e}"))?;
if !url.username().is_empty() || url.password().is_some() {
return Err(
"must not embed a username or password: the receiver URL is stored in workspace settings, where it is readable by workspace admins".to_string(),
);
}
if !matches!(url.scheme(), "http" | "https") {
// The scheme is submitted text too — `hunter2://host` parses fine — so it is
// named, not echoed, like every other branch here.
return Err("must use the http or https scheme".to_string());
}
if !url.has_host() {
return Err("must include a host".to_string());
}
if url.query().is_some() || url.fragment().is_some() {
return Err(
"must not include a query string or fragment, since the webhook path is appended to it"
.to_string(),
);
}
if value.chars().any(char::is_whitespace) {
return Err("must not contain whitespace".to_string());
}
// Matches the sibling `base_url` / `hub_base_url` convention. The receiver
// builder trims it anyway, so this is about keeping the stored value canonical
// rather than about reachability.
if value.ends_with('/') {
return Err("must not end with a trailing slash".to_string());
}
Ok(())
}
pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook";
pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries";
pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination";
pub const ALERT_CONFIG_SETTING: &str = "alert_job_queue_waiting";
// Workspace fairness: cloud-only mechanism that caps any single workspace at
// `workspace_fairness_max_percent`% of the shared worker pool once it has been
// occupying it for more than `workspace_fairness_duration_secs` seconds. See
// `windmill-queue/src/workspace_fairness.rs`.
pub const WORKSPACE_FAIRNESS_ENABLED_SETTING: &str = "workspace_fairness_enabled";
pub const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING: &str = "workspace_fairness_max_percent";
pub const WORKSPACE_FAIRNESS_DURATION_SECS_SETTING: &str = "workspace_fairness_duration_secs";
pub const WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING: &str = "workspace_fairness_min_total_jobs";
// Cloud-only ceiling on how many jobs may sit in the queue behind a single
// concurrency key. `0` disables the cap. See `windmill-queue/src/jobs.rs`,
// `check_concurrency_key_queue_cap`.
pub const CONCURRENCY_KEY_MAX_QUEUED_SETTING: &str = "concurrency_key_max_queued_jobs";
// Cloud-only ceiling on how many jobs a single workspace may have queued in
// total, across every key and script. Applies even to premium workspaces. `0`
// disables the cap. See `windmill-queue/src/jobs.rs`, `check_workspace_queue_cap`.
pub const WORKSPACE_MAX_QUEUED_JOBS_SETTING: &str = "workspace_max_queued_jobs";
/// Global settings an agent worker (a remote worker connected over HTTP instead
/// of to the database) must NEVER read through
/// `GET /api/agent_workers/get_global_setting/{key}`. Every other key is served.
///
/// SECURITY: that endpoint is authenticated only by an agent-worker JWT and
/// returns the raw `global_settings` value for the requested key. Because the
/// policy is deny-by-exception (anything not listed here is readable), every
/// setting that holds an instance secret or credential an agent worker does not
/// need MUST be listed below. Missing one discloses it to every agent worker —
/// `jwt_secret` is the worst case (a token holder could forge a superadmin JWT),
/// but `oauths`, `smtp_settings`, `secret_backend`, object-store credentials,
/// etc. are instance-wide secrets too.
///
/// NOT blocked, on purpose: the operational credentials an agent worker loads to
/// run jobs (`license_key`, `hub_api_secret`, `sandbox_registry_auth`,
/// `powershell_repo_pat`, `npmrc`, ...). Those are already within an agent
/// worker's trust boundary, and blocking them breaks worker startup or
/// dependency installation. When adding a new setting that stores a secret the
/// server keeps to itself, add it here.
pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[
// Instance identity / auth secrets — disclosure enables privilege escalation
// or impersonation.
JWT_SECRET_SETTING,
OAUTH_SETTING,
SMTP_SETTING,
SCIM_TOKEN_SETTING,
SAML_METADATA_SETTING,
SECRET_BACKEND_SETTING,
GITHUB_ENTERPRISE_APP_SETTING,
OBJECT_STORE_CONFIG_SETTING,
AI_CONFIG_SETTING,
TEAMS_SETTING,
INDEXER_SETTING,
// Server-only configs that may embed credentials, webhook URLs or tokens and
// are never loaded by an agent worker.
CRITICAL_ERROR_CHANNELS_SETTING,
INSTANCE_EVENTS_WEBHOOK_SETTING,
OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING,
// Custom-instance DB credentials: `custom_instance_pg_databases` holds `user_pwd`,
// `custom_instance_replication_pwd` holds the REPLICATION-role password. Agent workers
// resolve datatable connections through the dedicated datatable endpoints, never these.
"custom_instance_pg_databases",
"custom_instance_replication_pwd",
];
/// Whether an agent worker may read the given global setting over HTTP.
/// Deny-by-exception: everything is readable except [`AGENT_WORKER_BLOCKED_SETTINGS`].
pub fn is_setting_readable_by_agent_worker(name: &str) -> bool {
!AGENT_WORKER_BLOCKED_SETTINGS.contains(&name)
}
use std::sync::atomic::AtomicBool;
lazy_static::lazy_static! {
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false);
pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false);
/// Origins HTTP routes allow cross-origin when they configure none of their
/// own. Empty means unset, which keeps the historical `*`.
pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> =
arc_swap::ArcSwap::from_pointee(vec![]);
}
/// Whether an allowlist places no restriction at all.
///
/// `*` is the explicit "open on purpose" entry, and a route carrying it behaves
/// exactly as an unconfigured one: it is how a route opts out of a stricter
/// instance default, including back into the `wm_headers` escape hatch.
pub fn allows_any_origin(allowed_origins: &[String]) -> bool {
allowed_origins.iter().any(|allowed| allowed == "*")
}
/// An allowlist is scanned on every request to a restricted route, including
/// the unauthenticated preflight, so its size is a request cost anyone can
/// trigger.
pub const MAX_ALLOWED_ORIGINS: usize = 100;
pub const MAX_ALLOWED_ORIGIN_LEN: usize = 256;
/// Reject allowlist entries that cannot be compared, stored, or safely allowed.
///
/// The stored string is only ever an operand: `match_origin` echoes the
/// request's own `Origin` back, never this value, so a malformed entry matches
/// nothing and fails closed. Shapes that merely cannot match are the editor's
/// business to warn about, not this function's to refuse. What is left are the
/// three cases where permissiveness costs something: `null` is what every
/// sandboxed iframe sends, so allowing it would admit any page that can open
/// one; a comma cannot survive the editor's comma-separated field, which would
/// silently split one entry into two and widen the list; and an unbounded list
/// makes every preflight pay for it.
pub fn validate_allowed_origins(allowed_origins: &[String]) -> crate::error::Result<()> {
if allowed_origins.len() > MAX_ALLOWED_ORIGINS {
return Err(crate::error::Error::BadRequest(format!(
"At most {} allowed origins, got {}.",
MAX_ALLOWED_ORIGINS,
allowed_origins.len()
)));
}
for origin in allowed_origins {
if origin == "*" {
continue;
}
let invalid = |reason: &str| {
crate::error::Error::BadRequest(format!(
"Invalid allowed origin '{}': {}.",
origin, reason
))
};
if origin.is_empty() {
return Err(invalid("must not be empty"));
}
if origin.len() > MAX_ALLOWED_ORIGIN_LEN {
return Err(invalid("is longer than any origin a browser sends"));
}
// The editor edits the whole list as one comma-separated field, so an
// entry carrying a comma comes back as two and widens the list.
if origin.contains(',') {
return Err(invalid("must not contain a comma, which separates entries"));
}
if origin.eq_ignore_ascii_case("null") {
return Err(invalid(
"'null' is what a sandboxed iframe sends, so allowing it would allow any page that can open one",
));
}
// An Origin header is always visible ASCII, so a value outside it can
// never be the string this is compared against.
if !origin.chars().all(|c| c.is_ascii_graphic()) {
return Err(invalid(
"must contain only visible ASCII, with no whitespace",
));
}
}
Ok(())
}
/// Read [`HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING`] from its stored value.
///
/// Accepts the comma-separated string the settings UI writes, or a JSON array
/// for anything setting it through the API directly.
pub fn parse_allowed_origins_setting(
value: Option<&serde_json::Value>,
) -> crate::error::Result<Vec<String>> {
let origins = match value {
None | Some(serde_json::Value::Null) => vec![],
Some(serde_json::Value::String(raw)) => raw
.split(',')
.map(|origin| origin.trim().to_string())
.filter(|origin| !origin.is_empty())
.collect(),
Some(serde_json::Value::Array(entries)) => entries
.iter()
.map(|entry| match entry {
serde_json::Value::String(origin) => Ok(origin.trim().to_string()),
_ => Err(crate::error::Error::BadRequest(format!(
"{} entries must be strings",
HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING
))),
})
// Not filtered for empties, unlike the string form: there a
// trailing separator naturally yields an empty token, whereas an
// empty array entry is something the caller wrote and validation
// should reject rather than silently drop.
.collect::<crate::error::Result<Vec<_>>>()?,
Some(_) => {
return Err(crate::error::Error::BadRequest(format!(
"{} expected to be a comma-separated string or an array of strings",
HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING
)))
}
};
validate_allowed_origins(&origins)?;
Ok(origins)
}
pub const ENV_SETTINGS: &[&str] = &[
"DISABLE_NSJAIL",
"MODE",
"NUM_WORKERS",
"METRICS_ADDR",
"JSON_FMT",
"BASE_URL",
"TIMEOUT",
"ZOMBIE_JOB_TIMEOUT",
"RESTART_ZOMBIE_JOBS",
"SLEEP_QUEUE",
"MAX_LOG_SIZE",
"SERVER_BIND_ADDR",
"PORT",
"KEEP_JOB_DIR",
"S3_CACHE_BUCKET",
"COOKIE_DOMAIN",
"PYTHON_PATH",
"NU_PATH",
"DENO_PATH",
"GO_PATH",
"JAVA_PATH",
"RUBY_PATH",
"BUNDLE_PATH",
"GEM_PATH",
"RUBY_CONCURRENT_DOWNLOADS",
"RSCRIPT_PATH",
// for related places search: ADD_NEW_LANG
"GOPRIVATE",
"GOPROXY",
"NETRC",
"CARGO_REGISTRIES",
"INSTANCE_PYTHON_VERSION",
"PIP_INDEX_URL",
"PIP_EXTRA_INDEX_URL",
"PIP_TRUSTED_HOST",
"UV_PYTHON_INSTALL_MIRROR",
"PATH",
"HOME",
"DATABASE_CONNECTIONS",
"TIMEOUT_WAIT_RESULT",
"QUEUE_LIMIT_WAIT_RESULT",
"DENO_AUTH_TOKENS",
"DENO_FLAGS",
"NPM_CONFIG_REGISTRY",
"PIP_LOCAL_DEPENDENCIES",
"ADDITIONAL_PYTHON_PATHS",
"INCLUDE_HEADERS",
"INSTANCE_EVENTS_WEBHOOK",
"CLOUD_HOSTED",
"GLOBAL_CACHE_INTERVAL",
"WAIT_RESULT_FAST_POLL_DURATION_SECS",
"WAIT_RESULT_SLOW_POLL_INTERVAL_MS",
"WAIT_RESULT_FAST_POLL_INTERVAL_MS",
"EXIT_AFTER_NO_JOB_FOR_SECS",
"EXIT_AFTER_N_JOBS",
"WORKER_SUFFIX",
"REQUEST_SIZE_LIMIT",
"CREATE_WORKSPACE_REQUIRE_SUPERADMIN",
"GLOBAL_ERROR_HANDLER_PATH_IN_ADMINS_WORKSPACE",
"MAX_WAIT_FOR_SIGINT",
"MAX_WAIT_FOR_SIGTERM",
"JOB_OOM_SCORE_ADJ",
"WORKER_GROUP",
"SAML_METADATA",
"INSTANCE_IS_DEV",
"OTEL_METRICS",
"OTEL_TRACING",
"OTEL_LOGS",
// The OTEL_EXPORTER_OTLP_*HEADERS variables are left out: they carry exporter API keys, and
// this list is logged at startup and returned to superadmins by `get_local_settings`.
"OTEL_METRICS_ENABLED",
"OTEL_TRACING_ENABLED",
"OTEL_LOGS_ENABLED",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_COMPRESSION",
"OTEL_EXPORTER_OTLP_TIMEOUT",
"OTEL_EXPORTER_OTLP_TRACES_TIMEOUT",
"OTEL_EXPORTER_OTLP_METRICS_TIMEOUT",
"OTEL_EXPORTER_OTLP_LOGS_TIMEOUT",
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE",
"OTEL_METRIC_EXPORT_INTERVAL",
"OTEL_SERVICE_NAME",
"OTEL_SERVICE_VERSION",
"OTEL_HOST_NAME",
"OTEL_ENVIRONMENT",
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_JOB_LOGS",
"OTEL_TRACES_RETENTION_SECS",
"AI_SHARED_ARTIFACT_RETENTION_SECS",
"DISABLE_S3_STORE",
"PG_SCHEMA",
"PG_LISTENER_REFRESH_PERIOD_SECS",
"AI_REQUEST_TIMEOUT_SECONDS",
"JOB_CLEANUP_BATCH_SIZE",
"JOB_CLEANUP_MAX_BATCHES",
"AUTO_BUILD_BINARY_ON_DEPLOY",
"AUTO_BUILD_BINARY_TAG",
];
use crate::ee_oss::LicensePlan;
use crate::error;
use sqlx::postgres::Postgres;
use sqlx::Pool;
/// Read several settings in one round trip. Names with no row are simply absent from the
/// result, exactly as [`load_value_from_global_settings`] returns `None` for them.
pub async fn load_values_from_global_settings(
db: &Pool<Postgres>,
names: &[&str],
) -> error::Result<std::collections::HashMap<String, serde_json::Value>> {
// Listing the names keeps this on the primary key. `global_settings` also holds
// `workspace_dependencies_map_rebuilt:<workspace_id>`, one row per workspace with no
// cleanup path, so a predicate that scanned the table would grow with workspace count.
let rows = sqlx::query!(
"SELECT name, value FROM global_settings WHERE name = ANY($1)",
names as &[&str]
)
.fetch_all(db)
.await?;
Ok(rows.into_iter().map(|r| (r.name, r.value)).collect())
}
/// Return the instance's JWT secret, generating one only if the row holds nothing usable.
///
/// The write has to be conditional rather than a plain upsert, for two reasons. A usable
/// secret must never be overwritten: replicas booting together would each install their own
/// and reject each other's tokens. And `notify_global_setting_change` fires on every write to
/// this table, so an unconditional upsert would make each startup trigger a cluster-wide
/// settings reload. An empty `RETURNING` is how a caller learns another process's secret
/// stands, and reads that one instead.
///
/// Safe to call with a value read earlier: the statement, not the caller's read, decides.
pub async fn get_or_create_jwt_secret(db: &Pool<Postgres>) -> error::Result<String> {
let candidate = crate::utils::rd_string(32);
let stored = sqlx::query_scalar!(
"INSERT INTO global_settings (name, value) VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value
WHERE jsonb_typeof(global_settings.value) <> 'string'
RETURNING value",
JWT_SECRET_SETTING,
serde_json::to_value(&candidate)?
)
.fetch_optional(db)
.await?;
match stored {
Some(_) => Ok(candidate),
None => load_value_from_global_settings(db, JWT_SECRET_SETTING)
.await?
.and_then(|v| serde_json::from_value::<String>(v).ok())
.ok_or_else(|| {
error::Error::InternalErr(
"jwt_secret conflicted but holds no usable value".to_string(),
)
}),
}
}
pub async fn load_value_from_global_settings(
db: &Pool<Postgres>,
setting_name: &str,
) -> error::Result<Option<serde_json::Value>> {
let r = sqlx::query!(
"SELECT value FROM global_settings WHERE name = $1",
setting_name
)
.fetch_optional(db)
.await?
.map(|x| x.value);
Ok(r)
}
lazy_static::lazy_static! {
static ref AUTO_BUILD_BINARY_ON_DEPLOY_ENV: bool = std::env::var("AUTO_BUILD_BINARY_ON_DEPLOY")
.ok()
.and_then(|x| x.trim().parse::<bool>().ok())
.unwrap_or(false);
static ref AUTO_BUILD_BINARY_TAG_ENV: Option<String> = std::env::var("AUTO_BUILD_BINARY_TAG")
.ok()
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty());
}
/// Whether newly deployed scripts should have their binary built and pushed to the
/// instance object store, and on which worker tag.
///
/// `None` = off. `Some(tag_override)` = on, where `None` inside means "use the script's
/// language tag". Read per deployment rather than cached: deploys of a compiled language
/// are rare, and a stale cache here silently skips builds for as long as it lives.
///
/// The env fallbacks are read in whichever process decides — a server for a deploy that
/// brings its own lock, a worker for one that generates it — so on a split deployment they
/// belong on both. The instance setting has no such caveat; prefer it.
pub async fn auto_build_binary_on_deploy(
db: &Pool<Postgres>,
) -> error::Result<Option<Option<String>>> {
let enabled =
match load_value_from_global_settings(db, AUTO_BUILD_BINARY_ON_DEPLOY_SETTING).await? {
Some(serde_json::Value::Bool(b)) => b,
// An unset (or cleared) setting falls back to the env var, matching how the
// instance-settings UI leaves a never-touched toggle absent from the table.
None | Some(serde_json::Value::Null) => *AUTO_BUILD_BINARY_ON_DEPLOY_ENV,
Some(other) => {
tracing::error!(
"{AUTO_BUILD_BINARY_ON_DEPLOY_SETTING} is not a boolean: {other}, ignoring"
);
false
}
};
if !enabled {
return Ok(None);
}
// Without an instance object store the artifact never leaves the building worker's own
// disk, so every other worker would still compile it on first run.
if !instance_object_store_configured(db).await? {
tracing::warn!(
"{AUTO_BUILD_BINARY_ON_DEPLOY_SETTING} is enabled but no instance object storage is \
configured, not building anything"
);
return Ok(None);
}
let tag = match load_value_from_global_settings(db, AUTO_BUILD_BINARY_TAG_SETTING).await? {
Some(serde_json::Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) | None => {
AUTO_BUILD_BINARY_TAG_ENV.clone()
}
Some(other) => {
tracing::error!("{AUTO_BUILD_BINARY_TAG_SETTING} is not a string: {other}, ignoring");
None
}
};
Ok(Some(tag))
}
/// Whether an instance object store is configured, mirroring every condition
/// `windmill_object_store::reload_object_store_setting` needs to actually load one —
/// including its refusal on the Pro plan, without which a Pro instance holding an
/// object-store row would queue a build per deploy that can only ever skip.
///
/// Reads config rather than the loaded client so callers outside the worker (which may be
/// built without the object-store features) reach the same answer.
async fn instance_object_store_configured(db: &Pool<Postgres>) -> error::Result<bool> {
if matches!(crate::ee_oss::get_license_plan().await, LicensePlan::Pro) {
return Ok(false);
}
Ok(!matches!(
load_value_from_global_settings(db, OBJECT_STORE_CONFIG_SETTING).await?,
None | Some(serde_json::Value::Null)
) || std::env::var("S3_CACHE_BUCKET").is_ok())
}
/// Read OAuth client_id and client_secret from instance-level global settings.
/// `oauth_key` is the key under `oauths` (e.g., "gworkspace", "nextcloud").
pub async fn get_instance_oauth_credentials(
db: &Pool<Postgres>,
oauth_key: &str,
) -> error::Result<(String, String)> {
let oauths_value = load_value_from_global_settings(db, OAUTH_SETTING)
.await?
.ok_or_else(|| {
error::Error::InternalErr("Instance OAuth settings not found".to_string())
})?;
let entry = oauths_value.get(oauth_key).ok_or_else(|| {
error::Error::InternalErr(format!("No {} entry in instance OAuth settings", oauth_key))
})?;
let id = entry
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let secret = entry
.get("secret")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if id.is_empty() || secret.is_empty() {
return Err(error::Error::InternalErr(format!(
"Instance OAuth credentials for {} are incomplete",
oauth_key
)));
}
Ok((id, secret))
}
/// Map service client name to the OAuth settings key in global_settings.
/// e.g. "google" -> "gworkspace", "nextcloud" -> "nextcloud"
pub fn workspace_integration_oauth_key(client_name: &str) -> &str {
match client_name {
"google" => "gworkspace",
other => other,
}
}
/// Resolve the token endpoint URL for a workspace integration service.
pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -> String {
match client_name {
"google" => "https://oauth2.googleapis.com/token".to_string(),
_ => format!("{}/apps/oauth2/api/v1/token", base_url),
}
}
/// Resolve the auth endpoint URL for a workspace integration service.
pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> String {
match client_name {
"google" => "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
_ => format!("{}/apps/oauth2/authorize", base_url),
}
}
#[cfg(test)]
mod tests {
use super::*;
// `frontend/src/lib/tokenExpiration.test.ts` holds the same table for the token form's
// parser; the two must stay in step.
#[test]
fn max_token_expiration_days_accepts_only_whole_days_within_the_bound() {
use serde_json::json;
for (stored, days) in [
(json!(7), 7),
(json!(7.0), 7),
(json!("7"), 7),
(json!(" 30 "), 30),
(json!("+7"), 7),
(
json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND),
MAX_TOKEN_EXPIRATION_DAYS_BOUND,
),
] {
assert_eq!(
parse_max_token_expiration_days(Some(&stored)),
Ok(Some(days)),
"{stored}"
);
}
for cleared in [json!(null), json!(""), json!(" ")] {
assert_eq!(
parse_max_token_expiration_days(Some(&cleared)),
Ok(None),
"{cleared}"
);
}
assert_eq!(parse_max_token_expiration_days(None), Ok(None));
for bad in [
json!(7.5),
json!(0),
json!(-3),
json!("7.0"),
json!("1e1"),
json!("0x7"),
json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND + 1),
json!("99999999999999999999"),
json!(true),
json!([7]),
] {
assert!(
parse_max_token_expiration_days(Some(&bad)).is_err(),
"{bad} must be rejected"
);
}
}
#[test]
fn webhook_base_url_errors_never_echo_credentials() {
// These strings reach sync-config output and operator logs, so no branch may
// report the value verbatim. The secret is distinctive so this asserts on the
// credential leaking, not on the wording of the message.
const SECRET: &str = "hunter2xyzzy";
for bad in [
format!("https://admin:{SECRET}@hooks.example.com"),
format!("https://admin:{SECRET}@hooks.example.com?x=1"),
format!("https://admin:{SECRET}@hooks.example.com/"),
format!("https://admin:{SECRET}@"),
format!("https://admin:{SECRET}@hooks.example.com#f"),
format!("admin:{SECRET}@not-a-url"),
// Reach the parse-failure branch, the only one that sees an unvalidated
// string: multiple `@`, an invalid scheme, and no `//` at all.
format!("https://alias@admin:{SECRET}@["),
format!("https://a@b@admin:{SECRET}@hooks.example.com"),
format!("1x:{SECRET}@hooks.example.com"),
format!("ad min:{SECRET}@hooks.example.com"),
format!("{SECRET}@"),
// A query token is just as sensitive as userinfo and reaches the same logs.
format!("https://hooks.example.com?token={SECRET}"),
format!("https://hooks.example.com#{SECRET}"),
format!("https://hooks.example.com/{SECRET} x"),
// A custom scheme parses fine, so the scheme itself is attacker-chosen text.
format!("{SECRET}://hooks.example.com"),
] {
let err = validate_webhook_base_url(&bad).expect_err("should be rejected");
assert!(
!err.contains(SECRET),
"'{bad}' leaked its credential into: {err}"
);
}
}
#[test]
fn instance_banner_rejects_unsafe_and_malformed_values() {
// The link becomes the href of an anchor shown to every user of the instance,
// so a non-http(s) scheme must not survive a write.
for link in [
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"not-a-url",
"https://",
] {
let banner = serde_json::json!({ "enabled": true, "message": "down", "link": link });
assert!(
validate_instance_banner(&banner).is_err(),
"link '{link}' should be rejected"
);
}
// A wrong-typed field reads as absent to every accessor here, so without an
// explicit type check it would skip validation and be stored.
for bad in [
serde_json::json!({ "enabled": true, "message": "down", "link": 123 }),
serde_json::json!({ "enabled": true, "message": "down", "link_label": ["a"] }),
serde_json::json!({ "enabled": true, "message": { "text": "down" } }),
serde_json::json!({ "enabled": true, "message": "down", "severity": 2 }),
serde_json::json!({ "enabled": "yes", "message": "down" }),
serde_json::json!({ "enabled": true, "message": "down", "dismissible": "no" }),
] {
assert!(
validate_instance_banner(&bad).is_err(),
"{bad} should be rejected"
);
}
// The strip is one or two lines tall; both of its texts are bounded.
for (field, over) in [
("message", INSTANCE_BANNER_MESSAGE_MAX_LEN + 1),
("link_label", INSTANCE_BANNER_LINK_LABEL_MAX_LEN + 1),
] {
let mut banner = serde_json::json!({ "enabled": true, "message": "down" });
banner[field] = serde_json::Value::String("x".repeat(over));
assert!(
validate_instance_banner(&banner).is_err(),
"an over-long {field} should be rejected"
);
}
// Enabled with no message renders as nothing and must stay writable: every path
// into this validator is a bulk settings save, so rejecting it would fail an
// admin's unrelated edits over a half-typed announcement.
assert!(validate_instance_banner(&serde_json::json!({ "enabled": true })).is_ok());
let ok = serde_json::json!({
"enabled": true, "message": "down", "severity": "warning",
"link": "https://status.example.com", "dismissible": false
});
assert!(validate_instance_banner(&ok).is_ok());
}
#[test]
fn webhook_base_url_matches_the_ui_validator() {
// Kept in lockstep with `isValidWebhookBaseUrl` in
// frontend/src/lib/components/instanceSettings.ts: a value the field accepts
// must not 400 on save, and vice versa.
let accept = [
"https://hooks.example.com",
"http://hooks.example.com:8080",
"https://example.com/windmill",
" https://hooks.example.com ",
"https://[::1]:8000",
];
let reject = [
"httpss://hooks.example.com",
"hooks.example.com",
"ftp://hooks.example.com",
"https://",
"https://hooks.example.com?token=x",
"https://hooks.example.com#frag",
"https://hooks example.com",
"https://hooks.example.com/",
"https://hooks.example.com:abc",
"https://x/a b",
"https://hooks.example.com?",
"https://hooks.example.com#",
"https://user:password@hooks.example.com",
"https://user@hooks.example.com",
];
for v in accept {
assert!(
validate_webhook_base_url(v).is_ok(),
"'{v}' should be accepted"
);
}
for v in reject {
assert!(
validate_webhook_base_url(v).is_err(),
"'{v}' should be rejected"
);
}
}
#[test]
fn agent_workers_can_read_operational_settings() {
// Operational knobs and the credentials a worker needs to run jobs are
// intentionally NOT blocked. Deny-by-exception also means an arbitrary
// unlisted key is readable.
for key in [
NPMRC_SETTING,
PIP_INDEX_URL_SETTING,
JOB_ISOLATION_SETTING,
LICENSE_KEY_SETTING,
HUB_API_SECRET_SETTING,
SANDBOX_REGISTRY_AUTH_SETTING,
POWERSHELL_REPO_PAT_SETTING,
"some_future_operational_setting",
] {
assert!(
is_setting_readable_by_agent_worker(key),
"'{key}' must remain readable by agent workers"
);
}
}
#[test]
fn agent_workers_cannot_read_instance_secrets() {
// Disclosing any of these to a remote worker enables privilege
// escalation (jwt_secret -> forged superadmin JWT) or leaks instance
// secrets. They must never be served by the agent-worker endpoint.
for key in [
JWT_SECRET_SETTING,
OAUTH_SETTING,
SMTP_SETTING,
SCIM_TOKEN_SETTING,
SAML_METADATA_SETTING,
SECRET_BACKEND_SETTING,
GITHUB_ENTERPRISE_APP_SETTING,
OBJECT_STORE_CONFIG_SETTING,
AI_CONFIG_SETTING,
TEAMS_SETTING,
INDEXER_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING,
INSTANCE_EVENTS_WEBHOOK_SETTING,
OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING,
"custom_instance_pg_databases",
"custom_instance_replication_pwd",
] {
assert!(
!is_setting_readable_by_agent_worker(key),
"'{key}' is an instance secret and must not be readable by agent workers"
);
}
}
}
pub async fn set_value_in_global_settings(
db: &Pool<Postgres>,
setting_name: &str,
value: serde_json::Value,
) -> error::Result<()> {
sqlx::query!(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
setting_name,
value
)
.execute(db)
.await?;
Ok(())
}