use super::RuntimeConfigAvailability;
use crate::config::secret_registry::SecretRegistry;
use crate::config::toml::{
AllowedHostToml, ConfigName, DeploymentResolved, MethodsInput, ReplaceIn,
allowed_host_fingerprint, resolve_allowed_hosts,
};
use anyhow::{Context, bail};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Table, value};
use tracing::warn;
use wasm_workers::http_request_policy::{GlobalHttpConfig, ReplacementLocation};
#[derive(Default)]
struct LocatedWarnings {
by_message: BTreeMap<String, BTreeSet<String>>,
locations: BTreeMap<String, BTreeSet<(PathBuf, usize)>>,
}
impl LocatedWarnings {
fn index_file(&mut self, path: &Path) {
#[derive(Deserialize)]
struct AllowedHostBlock {
allowed_host: Vec<AllowedHostToml>,
}
let Ok(source) = std::fs::read_to_string(path) else {
return;
};
let lines = source.lines().collect::<Vec<_>>();
for (start, line) in lines.iter().enumerate() {
let header = line.split('#').next().unwrap_or_default().trim();
if !(header.starts_with("[[") && header.ends_with(".allowed_host]]")) {
continue;
}
let end = lines[start + 1..]
.iter()
.position(|line| {
line.split('#')
.next()
.unwrap_or_default()
.trim_start()
.starts_with('[')
})
.map_or(lines.len(), |offset| start + 1 + offset);
let mut block = String::from("[[allowed_host]]\n");
for line in &lines[start + 1..end] {
block.push_str(line);
block.push('\n');
}
let Ok(block) = toml::from_str::<AllowedHostBlock>(&block) else {
continue;
};
let Some(entry) = block.allowed_host.first() else {
continue;
};
self.locations
.entry(allowed_host_fingerprint(entry))
.or_default()
.insert((path.to_path_buf(), start + 1));
}
}
fn lint(
&mut self,
entries: &[AllowedHostToml],
ignore_missing_env_vars: bool,
secret_registry: &SecretRegistry,
) {
let Ok((_hosts, advisories)) =
resolve_allowed_hosts(entries.to_vec(), ignore_missing_env_vars, secret_registry)
else {
return;
};
for advisory in advisories {
let located = self
.locations
.get(&advisory.fingerprint)
.into_iter()
.flatten()
.map(|(path, line)| format!("{}:{line}", path.display()))
.collect::<Vec<_>>();
self.by_message
.entry(advisory.message)
.or_default()
.extend(located);
}
}
fn emit(self) {
if self.by_message.is_empty() {
return;
}
let lines = self
.by_message
.into_iter()
.map(|(message, locations)| {
if locations.is_empty() {
message
} else {
format!(
"{message} ({})",
locations.into_iter().collect::<Vec<_>>().join(", ")
)
}
})
.collect::<Vec<_>>();
warn!("Configuration warnings:\n- {}", lines.join("\n- "));
}
}
pub(super) fn preflight(
server_verified: &super::ServerVerified,
deployment: Option<&DeploymentResolved>,
availability: RuntimeConfigAvailability,
) -> Result<(), anyhow::Error> {
let secret_registry = &*server_verified.secret_registry;
let global_http_config = &server_verified.global_http_config;
let ignore_missing_env_vars = availability == RuntimeConfigAvailability::AllowUnavailable;
let mut warnings = LocatedWarnings::default();
if let Some(path) = &server_verified.source_path {
warnings.index_file(path);
}
if let Some(deployment) = deployment
&& let Some(path) = &deployment.source_path
{
warnings.index_file(path);
}
warnings.lint(
&server_verified.server_outbound_allowed_hosts,
false,
secret_registry,
);
let mut server_unregistered = BTreeSet::new();
collect_unregistered_allowed_host_secrets(
&server_verified.server_outbound_allowed_hosts,
secret_registry,
&mut server_unregistered,
);
let server_replacements = global_secret_replacements(global_http_config);
let mut missing_replacements = Vec::new();
let mut uncovered_hosts = Vec::new();
let mut unregistered = BTreeSet::new();
if let Some(deployment) = deployment {
let mut check = |section: &'static str, name: &ConfigName, hosts: &[AllowedHostToml]| {
warnings.lint(hosts, ignore_missing_env_vars, secret_registry);
collect_outbound_http_secret_replacements(
section,
name,
hosts,
&server_replacements,
secret_registry,
&mut missing_replacements,
);
collect_uncovered_outbound_http_hosts(
section,
name,
hosts,
global_http_config,
secret_registry,
ignore_missing_env_vars,
&mut uncovered_hosts,
);
collect_unregistered_allowed_host_secrets(hosts, secret_registry, &mut unregistered);
};
for activity in &deployment.activities_wasm {
check(
"activity_wasm",
&activity.common.name,
&activity.allowed_hosts,
);
}
for activity in &deployment.activities_js {
check("activity_js", &activity.name, &activity.allowed_hosts);
}
for webhook in &deployment.webhooks_wasm {
check(
"webhook_endpoint_wasm",
&webhook.common.name,
&webhook.allowed_hosts,
);
}
for webhook in &deployment.webhooks_js {
check("webhook_endpoint_js", &webhook.name, &webhook.allowed_hosts);
}
for exec in &deployment.activities_exec {
for name in &exec.secrets {
if secret_registry.secret_lookup(name).is_none() {
unregistered.insert(name.clone());
}
}
}
}
warnings.emit();
report_uncovered_outbound_http_hosts(&uncovered_hosts);
report_unregistered_secrets(
&server_unregistered,
"server.toml `[[outbound_http.allowed_host]]` entries",
RuntimeConfigAvailability::Strict,
)?;
report_unregistered_secrets(
&unregistered,
"the deployment's secret references",
availability,
)?;
report_missing_outbound_http_secret_replacements(&missing_replacements, availability)?;
Ok(())
}
pub(super) fn deployment_allowed_host_lists(
deployment: &DeploymentResolved,
) -> Vec<&[AllowedHostToml]> {
let mut lists: Vec<&[AllowedHostToml]> = Vec::new();
lists.extend(deployment.activities_wasm.iter().map(|c| &*c.allowed_hosts));
lists.extend(deployment.activities_js.iter().map(|c| &*c.allowed_hosts));
lists.extend(deployment.webhooks_wasm.iter().map(|c| &*c.allowed_hosts));
lists.extend(deployment.webhooks_js.iter().map(|c| &*c.allowed_hosts));
lists
}
pub(super) fn collect_deployment_unregistered_secrets(
deployment: &DeploymentResolved,
secret_registry: &SecretRegistry,
unregistered: &mut BTreeSet<String>,
) {
for hosts in deployment_allowed_host_lists(deployment) {
collect_unregistered_allowed_host_secrets(hosts, secret_registry, unregistered);
}
for exec in &deployment.activities_exec {
for name in &exec.secrets {
if secret_registry.secret_lookup(name).is_none() {
unregistered.insert(name.clone());
}
}
}
}
pub(super) fn collect_unregistered_allowed_host_secrets(
entries: &[AllowedHostToml],
secret_registry: &SecretRegistry,
unregistered: &mut BTreeSet<String>,
) {
for entry in entries {
for secret in &entry.secrets {
if secret_registry.secret_lookup(secret).is_none() {
unregistered.insert(secret.clone());
}
}
}
}
pub(super) fn secret_scaffold_snippet(names: &BTreeSet<String>) -> String {
use std::fmt::Write as _;
let mut snippet = String::from("[secrets]\n");
for name in names {
let _ = writeln!(snippet, "{name} = {{ env = \"{name}\" }}");
}
snippet
}
pub(super) fn report_unregistered_secrets(
unregistered: &BTreeSet<String>,
source_desc: &str,
availability: RuntimeConfigAvailability,
) -> Result<(), anyhow::Error> {
if unregistered.is_empty() {
return Ok(());
}
let list = unregistered
.iter()
.cloned()
.collect::<Vec<_>>()
.join("`, `");
let snippet = secret_scaffold_snippet(unregistered);
let message = format!(
"{source_desc} reference(s) secret(s) `{list}` that are not registered in the server \
`[secrets]` table.\n\
Add them to server.toml (adjust each `env` to its source variable), or remove the \
references. Run again with `--fix` to scaffold them:\n\n\
{snippet}"
);
if availability == RuntimeConfigAvailability::AllowUnavailable {
warn!(
"{message}\nSkipping these load-time secret checks because unavailable runtime \
configuration is allowed; activation will enforce them strictly."
);
Ok(())
} else {
bail!("{message}");
}
}
pub(super) fn global_secret_replacements(
global_http_config: &GlobalHttpConfig,
) -> hashbrown::HashSet<(&str, ReplacementLocation)> {
global_http_config
.entries()
.iter()
.flat_map(|entry| {
entry.secret_names.iter().flat_map(|name| {
entry
.replace_in
.iter()
.copied()
.map(move |target| (name.as_str(), target))
})
})
.collect()
}
fn replacement_target(replace_in: ReplaceIn) -> ReplacementLocation {
match replace_in {
ReplaceIn::Headers => ReplacementLocation::Headers,
ReplaceIn::Body => ReplacementLocation::Body,
ReplaceIn::Params => ReplacementLocation::Params,
}
}
fn replacement_target_name(replace_in: ReplaceIn) -> &'static str {
match replace_in {
ReplaceIn::Headers => "headers",
ReplaceIn::Body => "body",
ReplaceIn::Params => "params",
}
}
fn allowed_host_snippet(
section: &str,
entry: &AllowedHostToml,
secret: &str,
replace_in: ReplaceIn,
) -> String {
let mut entry = entry.clone();
entry.secrets = vec![secret.to_string()];
entry.replace_in = vec![replace_in];
let body = toml::to_string(&entry).expect("AllowedHostToml must serialize to TOML");
format!("[[{section}.allowed_host]]\n{body}")
}
pub(super) struct MissingSecretReplacement {
component_section: &'static str,
component_name: ConfigName,
entry: AllowedHostToml,
secret: String,
replace_in: ReplaceIn,
}
pub(super) fn collect_outbound_http_secret_replacements(
component_section: &'static str,
component_name: &ConfigName,
entries: &[AllowedHostToml],
server_replacements: &hashbrown::HashSet<(&str, ReplacementLocation)>,
secret_registry: &SecretRegistry,
missing: &mut Vec<MissingSecretReplacement>,
) {
for entry in entries {
if entry.methods.is_none()
|| matches!(&entry.methods, Some(MethodsInput::List(methods)) if methods.is_empty())
{
continue;
}
for secret in &entry.secrets {
if secret_registry.secret_lookup(secret).is_none() {
continue;
}
for replace_in in &entry.replace_in {
let requested_replacement = (secret.as_str(), replacement_target(*replace_in));
if server_replacements.contains(&requested_replacement) {
continue;
}
missing.push(MissingSecretReplacement {
component_section,
component_name: component_name.clone(),
entry: entry.clone(),
secret: secret.clone(),
replace_in: *replace_in,
});
}
}
}
}
pub(super) fn report_missing_outbound_http_secret_replacements(
missing: &[MissingSecretReplacement],
availability: RuntimeConfigAvailability,
) -> Result<(), anyhow::Error> {
if missing.is_empty() {
return Ok(());
}
use std::fmt::Write as _;
let mut details = String::new();
let mut server_snippets: Vec<String> = Vec::new();
let mut deployment_snippets: Vec<String> = Vec::new();
for m in missing {
let _ = writeln!(
details,
" - component `{name}` (`[[{section}.allowed_host]]`) requests replacing secret \
`{secret}` in `{target}`",
name = m.component_name,
section = m.component_section,
secret = m.secret,
target = replacement_target_name(m.replace_in),
);
let server_snippet =
allowed_host_snippet("outbound_http", &m.entry, &m.secret, m.replace_in);
if !server_snippets.contains(&server_snippet) {
server_snippets.push(server_snippet);
}
let deployment_snippet =
allowed_host_snippet(m.component_section, &m.entry, &m.secret, m.replace_in);
if !deployment_snippets.contains(&deployment_snippet) {
deployment_snippets.push(deployment_snippet);
}
}
let message = format!(
"the deployment requests {count} outbound HTTP secret replacement(s) that no server.toml \
`[[outbound_http.allowed_host]]` entry authorizes:\n\
{details}\n\
After review, add these allowlist entries to server.toml:\n\n\
{server}\n\
The corresponding deployment.toml entries are:\n\n\
{deployment}",
count = missing.len(),
server = server_snippets.join("\n"),
deployment = deployment_snippets.join("\n"),
);
if availability == RuntimeConfigAvailability::AllowUnavailable {
warn!(
"{message}\nSkipping these load-time secret replacement checks because unavailable \
runtime configuration is allowed; activation will enforce them strictly."
);
Ok(())
} else {
bail!("{message}");
}
}
pub(super) struct UncoveredOutboundHost {
component_section: &'static str,
component_name: ConfigName,
pub(super) entry: AllowedHostToml,
}
pub(super) fn host_allowlist_snippet(section: &str, entry: &AllowedHostToml) -> String {
#[derive(serde::Serialize)]
struct DestinationEntry {
pattern: String,
#[serde(skip_serializing_if = "Option::is_none")]
methods: Option<MethodsInput>,
#[serde(skip_serializing_if = "Option::is_none")]
request_url_regex: Option<String>,
}
let body = toml::to_string(&DestinationEntry {
pattern: entry.pattern.clone(),
methods: entry.methods.clone(),
request_url_regex: entry.request_url_regex.clone(),
})
.expect("destination allowlist entry must serialize to TOML");
format!("[[{section}.allowed_host]]\n{body}")
}
pub(super) fn collect_uncovered_outbound_http_hosts(
component_section: &'static str,
component_name: &ConfigName,
entries: &[AllowedHostToml],
global_http_config: &GlobalHttpConfig,
secret_registry: &SecretRegistry,
ignore_missing_env_vars: bool,
uncovered: &mut Vec<UncoveredOutboundHost>,
) {
for entry in entries {
if entry.methods.is_none()
|| matches!(&entry.methods, Some(MethodsInput::List(methods)) if methods.is_empty())
{
continue;
}
let Ok((resolved, _advisories)) = resolve_allowed_hosts(
vec![entry.clone()],
ignore_missing_env_vars,
secret_registry,
) else {
continue;
};
let Some(resolved) = resolved.first() else {
continue;
};
if !global_http_config
.entries()
.iter()
.any(|allowed| allowed.covers(resolved))
{
uncovered.push(UncoveredOutboundHost {
component_section,
component_name: component_name.clone(),
entry: entry.clone(),
});
}
}
}
pub(super) fn report_uncovered_outbound_http_hosts(uncovered: &[UncoveredOutboundHost]) {
if uncovered.is_empty() {
return;
}
use std::fmt::Write as _;
let mut details = String::new();
let mut snippets: Vec<String> = Vec::new();
for host in uncovered {
let _ = writeln!(
details,
" - component `{name}` (`[[{section}.allowed_host]]`) allows `{pattern}`",
name = host.component_name,
section = host.component_section,
pattern = host.entry.pattern,
);
let snippet = host_allowlist_snippet("outbound_http", &host.entry);
if !snippets.contains(&snippet) {
snippets.push(snippet);
}
}
warn!(
"{count} outbound HTTP destination(s) the deployment allows are not covered by any \
server.toml `[[outbound_http.allowed_host]]` allowlist entry; requests to them will be \
denied at runtime:\n\
{details}\n\
After review, add these allowlist entries to server.toml:\n\n\
{snippets}",
count = uncovered.len(),
snippets = snippets.join("\n"),
);
}
pub(super) async fn fix_server_secret_scaffolds(
server_config_path: &Path,
names: &BTreeSet<String>,
) -> Result<(), anyhow::Error> {
if names.is_empty() {
return Ok(());
}
let source = tokio::fs::read_to_string(server_config_path)
.await
.with_context(|| format!("cannot read server config {server_config_path:?}"))?;
let mut doc = source
.parse::<DocumentMut>()
.context("cannot parse server config as TOML")?;
let table = doc
.as_table_mut()
.entry("secrets")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.context("`secrets` in server config is not a table")?;
for name in names {
if table.contains_key(name) {
bail!("secret {name} must not be present");
}
let mut inline = toml_edit::InlineTable::new();
inline.insert("env", name.as_str().into());
table.insert(name, value(inline));
}
tokio::fs::write(server_config_path, doc.to_string())
.await
.with_context(|| format!("cannot write fixed server config {server_config_path:?}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn advisory_is_located_to_source_line() {
let entry = AllowedHostToml {
pattern: "http://localhost:5005".to_string(),
methods: None,
request_url_regex: None,
secrets: Vec::new(),
replace_in: Vec::new(),
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.toml");
std::fs::write(
&path,
format!(
"# policy\n[[outbound_http.allowed_host]]\n{}",
toml::to_string(&entry).unwrap()
),
)
.unwrap();
let mut warnings = LocatedWarnings::default();
warnings.index_file(&path);
warnings.lint(&[entry], false, &SecretRegistry::empty());
let (message, locations) = warnings.by_message.iter().next().expect("one advisory");
assert!(message.contains("has no `methods`"), "{message}");
let location = locations.iter().next().expect("one location");
assert!(location.ends_with("server.toml:2"), "{location}");
}
}