use hyper::Uri;
use hyper::http::Method;
use rand::RngCore;
use regex::Regex;
use secrecy::{ExposeSecret, SecretString};
use std::fmt;
use std::sync::Arc;
use tracing::{debug, trace};
use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ReplacementLocation {
Headers,
Body,
Params,
}
#[derive(Clone, Debug)]
pub struct PlaceholderSecret {
pub name: String,
pub placeholder: String,
pub real_value: SecretString,
pub replace_in: hashbrown::HashSet<ReplacementLocation>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, derive_more::Display)]
pub enum SchemePattern {
#[display("http")]
Http,
#[display("https")]
Https,
#[display("*")]
Any,
}
impl SchemePattern {
#[must_use]
pub fn allows_unencrypted(&self) -> bool {
matches!(self, SchemePattern::Http | SchemePattern::Any)
}
fn covers(&self, inner: &SchemePattern) -> bool {
matches!(self, SchemePattern::Any) || self == inner
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PortPattern {
Specific(u16),
Any,
Default,
}
impl PortPattern {
fn covers(&self, inner: &PortPattern) -> bool {
matches!(self, PortPattern::Any) || self == inner
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum MethodsPattern {
AllMethods,
Specific(Vec<Method>),
}
impl MethodsPattern {
fn covers(&self, inner: &MethodsPattern) -> bool {
match (self, inner) {
(MethodsPattern::AllMethods, _) => true,
(MethodsPattern::Specific(_), MethodsPattern::AllMethods) => false,
(MethodsPattern::Specific(outer), MethodsPattern::Specific(inner)) => {
inner.iter().all(|m| outer.contains(m))
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct HostPattern {
pub scheme: SchemePattern,
pub host_pattern: String,
pub port: PortPattern,
pub methods: MethodsPattern,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum HostPatternError {
#[error("wildcard `*` must be the first or last character in host pattern: `{host}`")]
Wildcard { host: String },
#[error("host pattern must not contain a path: `{input}`")]
ContainsPath { input: String },
}
impl HostPattern {
pub fn parse_with_methods(
input: &str,
methods: MethodsPattern,
) -> Result<Self, HostPatternError> {
let mut host_pattern = Self::parse(input)?;
host_pattern.methods = methods;
Ok(host_pattern)
}
fn parse(input: &str) -> Result<Self, HostPatternError> {
let (scheme, rest) = if let Some(rest) = input.strip_prefix("*://") {
(SchemePattern::Any, rest)
} else if let Some(rest) = input.strip_prefix("https://") {
(SchemePattern::Https, rest)
} else if let Some(rest) = input.strip_prefix("http://") {
(SchemePattern::Http, rest)
} else {
(SchemePattern::Https, input)
};
if rest.contains('/') {
return Err(HostPatternError::ContainsPath {
input: input.to_string(),
});
}
let (host_port_str, any_port) = if let Some(stripped) = rest.strip_suffix(":*") {
(stripped, true)
} else {
(rest, false)
};
let (host, port) = if any_port {
(host_port_str.to_string(), PortPattern::Any)
} else if let Some((h, p)) = host_port_str.rsplit_once(':') {
if let Ok(port_num) = p.parse::<u16>() {
(h.to_string(), PortPattern::Specific(port_num))
} else {
(host_port_str.to_string(), PortPattern::Default)
}
} else {
(host_port_str.to_string(), PortPattern::Default)
};
if host.contains('*') && !host.starts_with('*') && !host.ends_with('*') {
return Err(HostPatternError::Wildcard { host });
}
Ok(HostPattern {
scheme,
host_pattern: host,
port,
methods: MethodsPattern::AllMethods,
})
}
#[must_use]
fn matches(&self, scheme: &str, host: &str, port: u16, method: &Method) -> bool {
let scheme_matches = match &self.scheme {
SchemePattern::Http => scheme == "http",
SchemePattern::Https => scheme == "https",
SchemePattern::Any => scheme == "http" || scheme == "https",
};
if !scheme_matches {
return false;
}
let port_matches = match &self.port {
PortPattern::Specific(p) => port == *p,
PortPattern::Any => true,
PortPattern::Default => {
match scheme {
"http" => port == 80,
"https" => port == 443,
_ => false,
}
}
};
if !port_matches {
return false;
}
if !match_wildcard(&self.host_pattern, host) {
return false;
}
match &self.methods {
MethodsPattern::AllMethods => true,
MethodsPattern::Specific(methods) => methods.contains(method),
}
}
fn covers(&self, inner: &HostPattern) -> bool {
self.scheme.covers(&inner.scheme)
&& self.port.covers(&inner.port)
&& self.methods.covers(&inner.methods)
&& host_pattern_covers(&self.host_pattern, &inner.host_pattern)
}
}
impl fmt::Display for HostPattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}://{}", self.scheme, self.host_pattern)?;
match &self.port {
PortPattern::Specific(p) => write!(f, ":{p}")?,
PortPattern::Any => write!(f, ":*")?,
PortPattern::Default => {} }
match &self.methods {
MethodsPattern::AllMethods => {} MethodsPattern::Specific(methods) if methods.is_empty() => {
write!(f, " [NONE]")?;
}
MethodsPattern::Specific(methods) => {
let method_strs: Vec<&str> = methods.iter().map(Method::as_str).collect();
write!(f, " [{}]", method_strs.join(", "))?;
}
}
Ok(())
}
}
fn match_wildcard(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(suffix) = pattern.strip_prefix('*') {
return value.ends_with(suffix);
}
if let Some(prefix) = pattern.strip_suffix('*') {
return value.starts_with(prefix);
}
pattern == value
}
fn host_pattern_covers(outer: &str, inner: &str) -> bool {
if outer == inner || outer == "*" {
return true;
}
if let Some(suffix) = outer.strip_prefix('*') {
return match (inner.strip_prefix('*'), inner.strip_suffix('*')) {
(Some(inner_suffix), _) => inner_suffix.ends_with(suffix), (None, Some(_)) => false, (None, None) => inner.ends_with(suffix), };
}
if let Some(prefix) = outer.strip_suffix('*') {
return match (inner.strip_prefix('*'), inner.strip_suffix('*')) {
(Some(_), None) => false, (_, Some(inner_prefix)) => inner_prefix.starts_with(prefix), (None, None) => inner.starts_with(prefix), };
}
false
}
#[derive(Clone, Debug)]
pub struct AllowedHostPolicy {
pub pattern: HostPattern,
pub request_url_regex: Option<Regex>,
pub secrets: Vec<PlaceholderSecret>,
}
impl AllowedHostPolicy {
fn matches_request(
&self,
scheme: &str,
host: &str,
port: u16,
method: &Method,
request_match: &str,
) -> bool {
self.pattern.matches(scheme, host, port, method)
&& self
.request_url_regex
.as_ref()
.is_none_or(|regex| regex.is_match(request_match))
}
}
#[derive(Clone, Debug, Default)]
pub struct HttpRequestPolicy {
pub hosts: Vec<AllowedHostPolicy>,
pub global_allowlist: Option<Vec<AllowedHostPolicy>>,
}
fn filter_matching<'a>(
hosts: &'a [AllowedHostPolicy],
scheme: &str,
host: &str,
port: u16,
method: &Method,
request_match: &str,
) -> Vec<&'a AllowedHostPolicy> {
hosts
.iter()
.filter(|h| h.matches_request(scheme, host, port, method, request_match))
.collect()
}
fn effective_secrets<'a>(
matching_component: &[&'a AllowedHostPolicy],
matching_global: Option<&[&AllowedHostPolicy]>,
location: ReplacementLocation,
) -> Vec<&'a PlaceholderSecret> {
let requested = matching_component
.iter()
.flat_map(|h| h.secrets.iter())
.filter(|s| s.replace_in.contains(&location));
match matching_global {
None => requested.collect(),
Some(global) => {
let authorized: hashbrown::HashSet<&str> = global
.iter()
.flat_map(|h| h.secrets.iter())
.filter(|s| s.replace_in.contains(&location))
.map(|s| s.name.as_str())
.collect();
requested
.filter(|s| authorized.contains(s.name.as_str()))
.collect()
}
}
}
#[must_use]
pub fn is_text_content_type(content_type: &str) -> bool {
let ct = content_type.to_ascii_lowercase();
ct.starts_with("text/")
|| ct.starts_with("application/json")
|| ct.contains("+json")
|| ct.starts_with("application/x-www-form-urlencoded")
}
fn extract_request_target(uri: &hyper::Uri) -> Option<(String, String, u16, String)> {
let scheme = uri.scheme_str().unwrap_or("https").to_string();
let host = uri.host()?.to_string();
let default_port = if scheme == "https" { 443 } else { 80 };
let port = uri.port_u16().unwrap_or(default_port);
let path = uri.path().to_string();
Some((scheme, host, port, path))
}
fn request_match_input(uri: &hyper::Uri, method: &Method) -> Option<String> {
let scheme = uri.scheme_str().unwrap_or("https");
let authority = uri.authority()?.as_str();
Some(format!("{method} {scheme}://{authority}{}", uri.path()))
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum PolicyError {
#[error("outgoing HTTP request has no host in URI: {0}")]
RequestHasNoHost(Uri),
#[error("outgoing HTTP request {request_url} denied by {denied_by}")]
RequestDenied {
method: Method,
scheme: String,
host: String,
port: u16,
path: String,
request_url: String,
denied_by: PolicyLayer,
},
}
#[derive(Clone, Copy, Debug, derive_more::Display)]
pub(crate) enum PolicyLayer {
#[display("deployment.toml component policy")]
Component,
#[display("server.toml outbound HTTP allowlist")]
GlobalAllowlist,
}
impl From<PolicyError> for ErrorCode {
fn from(_value: PolicyError) -> Self {
ErrorCode::HttpRequestDenied
}
}
impl HttpRequestPolicy {
pub(crate) fn apply(
&self,
request: &mut hyper::Request<wasmtime_wasi_http::p2::body::HyperOutgoingBody>,
) -> Result<(), PolicyError> {
let Some((scheme, host, port, path)) = extract_request_target(request.uri()) else {
return Err(PolicyError::RequestHasNoHost(request.uri().clone()));
};
let request_match = request_match_input(request.uri(), request.method())
.ok_or_else(|| PolicyError::RequestHasNoHost(request.uri().clone()))?;
let method = request.method().clone();
let deny = |denied_by| PolicyError::RequestDenied {
method: method.clone(),
scheme: scheme.clone(),
host: host.clone(),
port,
path: path.clone(),
request_url: request_match.clone(),
denied_by,
};
let matching: Vec<&AllowedHostPolicy> =
filter_matching(&self.hosts, &scheme, &host, port, &method, &request_match);
if matching.is_empty() {
return Err(deny(PolicyLayer::Component));
}
let matching_global: Option<Vec<&AllowedHostPolicy>> = match &self.global_allowlist {
Some(global) => {
let m = filter_matching(global, &scheme, &host, port, &method, &request_match);
if m.is_empty() {
return Err(deny(PolicyLayer::GlobalAllowlist));
}
Some(m)
}
None => None,
};
let matching_global = matching_global.as_deref();
let header_secrets =
effective_secrets(&matching, matching_global, ReplacementLocation::Headers);
if !header_secrets.is_empty() {
let headers = request.headers_mut();
let keys: Vec<_> = headers.keys().cloned().collect();
for key in keys {
if let Some(val) = headers.get(&key)
&& let Ok(val_str) = val.to_str()
{
let mut replaced = val_str.to_string();
for secret in &header_secrets {
replaced = replaced
.replace(&secret.placeholder, secret.real_value.expose_secret());
}
if replaced != val_str
&& let Ok(new_val) = hyper::header::HeaderValue::from_str(&replaced)
{
headers.insert(&key, new_val);
}
}
}
}
let param_secrets =
effective_secrets(&matching, matching_global, ReplacementLocation::Params);
if !param_secrets.is_empty()
&& let Some(query) = request.uri().query()
{
let mut changed = false;
let pairs = url::form_urlencoded::parse(query.as_bytes())
.map(|(name, value)| {
let mut replaced = value.to_string();
for secret in ¶m_secrets {
replaced = replaced
.replace(&secret.placeholder, secret.real_value.expose_secret());
}
changed |= replaced != value.as_ref();
(name.into_owned(), replaced)
})
.collect::<Vec<_>>();
if changed {
let query = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(pairs)
.finish();
let path_and_query = format!("{}?{query}", request.uri().path());
let mut parts = request.uri().clone().into_parts();
if let Ok(path_and_query) = path_and_query.parse() {
parts.path_and_query = Some(path_and_query);
if let Ok(uri) = hyper::Uri::from_parts(parts) {
*request.uri_mut() = uri;
}
}
}
}
Ok(())
}
fn body_secrets_for(&self, uri: &hyper::Uri, method: &Method) -> Vec<&PlaceholderSecret> {
let Some((scheme, host, port, _path)) = extract_request_target(uri) else {
return Vec::new();
};
let Some(request_match) = request_match_input(uri, method) else {
return Vec::new();
};
let matching = filter_matching(&self.hosts, &scheme, &host, port, method, &request_match);
if matching.is_empty() {
return Vec::new();
}
let matching_global = match &self.global_allowlist {
Some(global) => {
let m = filter_matching(global, &scheme, &host, port, method, &request_match);
if m.is_empty() {
return Vec::new();
}
Some(m)
}
None => None,
};
effective_secrets(
&matching,
matching_global.as_deref(),
ReplacementLocation::Body,
)
}
pub(crate) async fn apply_body_replacement(
&self,
request: &mut hyper::Request<wasmtime_wasi_http::p2::body::HyperOutgoingBody>,
) {
let body_secrets = self.body_secrets_for(request.uri(), request.method());
if body_secrets.is_empty() {
trace!("No secrets, no modifications to HTTP body");
return;
}
let should_replace = request
.headers()
.get(hyper::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(is_text_content_type)
.unwrap_or(false);
if !should_replace {
return;
}
let body = std::mem::take(request.body_mut());
let Ok(collected) = http_body_util::BodyExt::collect(body).await else {
return;
};
let body_bytes = collected.to_bytes();
let Ok(mut body_str) = String::from_utf8(body_bytes.to_vec()) else {
let restored =
http_body_util::combinators::UnsyncBoxBody::new(http_body_util::BodyExt::map_err(
http_body_util::Full::new(body_bytes),
|_| unreachable!(),
));
*request.body_mut() = restored;
debug!("Not valid UTF-8, sending original HTTP body");
return;
};
for secret in &body_secrets {
body_str = body_str.replace(&secret.placeholder, secret.real_value.expose_secret());
}
let new_body =
http_body_util::combinators::UnsyncBoxBody::new(http_body_util::BodyExt::map_err(
http_body_util::Full::new(hyper::body::Bytes::from(body_str)),
|_| unreachable!(),
));
*request.body_mut() = new_body;
debug!("Applied secrets to HTTP body");
}
}
#[must_use]
pub fn generate_placeholder() -> String {
let mut random_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut random_bytes);
use std::fmt::Write;
let hex = random_bytes
.iter()
.fold(String::with_capacity(64), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
});
format!("OBELISK_SECRET_{hex}")
}
pub trait SecretResolver: Send + Sync + fmt::Debug {
fn secret_lookup(&self, name: &str) -> Option<SecretString>;
}
#[derive(Clone, Copy, Debug)]
pub struct NoSecrets;
impl SecretResolver for NoSecrets {
fn secret_lookup(&self, _name: &str) -> Option<SecretString> {
None
}
}
#[cfg(test)]
#[derive(Debug, Default)]
pub(crate) struct TestSecretResolver(pub(crate) hashbrown::HashMap<String, SecretString>);
#[cfg(test)]
impl SecretResolver for TestSecretResolver {
fn secret_lookup(&self, name: &str) -> Option<SecretString> {
self.0.get(name).cloned()
}
}
#[derive(Clone, Debug)]
pub struct AllowedHostConfig {
pub pattern: HostPattern,
pub request_url_regex: Option<Regex>,
pub secret_names: Vec<String>,
pub replace_in: hashbrown::HashSet<ReplacementLocation>,
}
impl AllowedHostConfig {
#[must_use]
pub fn covers(&self, inner: &AllowedHostConfig) -> bool {
self.pattern.covers(&inner.pattern)
&& match (&self.request_url_regex, &inner.request_url_regex) {
(None, _) => true,
(Some(_), None) => false,
(Some(outer), Some(inner)) => outer.as_str() == inner.as_str(),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct GlobalHttpConfig(Arc<[AllowedHostConfig]>);
impl GlobalHttpConfig {
#[must_use]
pub fn entries(&self) -> &[AllowedHostConfig] {
&self.0
}
}
impl From<Arc<[AllowedHostConfig]>> for GlobalHttpConfig {
fn from(entries: Arc<[AllowedHostConfig]>) -> Self {
Self(entries)
}
}
impl From<Vec<AllowedHostConfig>> for GlobalHttpConfig {
fn from(entries: Vec<AllowedHostConfig>) -> Self {
Self(entries.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(spec: &str, methods: MethodsPattern, regex: Option<&str>) -> AllowedHostConfig {
AllowedHostConfig {
pattern: HostPattern::parse_with_methods(spec, methods).unwrap(),
request_url_regex: regex.map(|r| Regex::new(r).unwrap()),
secret_names: Vec::new(),
replace_in: hashbrown::HashSet::new(),
}
}
#[test]
fn covers_exact_and_superset() {
let all = MethodsPattern::AllMethods;
let get = || MethodsPattern::Specific(vec![Method::GET]);
let get_post = || MethodsPattern::Specific(vec![Method::GET, Method::POST]);
assert!(cfg("obeli.sk", get(), None).covers(&cfg("obeli.sk", get(), None)));
assert!(!cfg("other.example.com", all.clone(), None).covers(&cfg("obeli.sk", get(), None)));
assert!(cfg("*.example.com", all.clone(), None).covers(&cfg(
"api.example.com",
get(),
None
)));
assert!(!cfg("api.example.com", all.clone(), None).covers(&cfg(
"*.example.com",
get(),
None
)));
assert!(cfg("obeli.sk", all.clone(), None).covers(&cfg("obeli.sk", get(), None)));
assert!(cfg("obeli.sk", get_post(), None).covers(&cfg("obeli.sk", get(), None)));
assert!(!cfg("obeli.sk", get(), None).covers(&cfg("obeli.sk", get_post(), None)));
assert!(!cfg("obeli.sk", get(), None).covers(&cfg("obeli.sk", all.clone(), None)));
}
#[test]
fn covers_request_url_regex_is_conservative() {
let get = || MethodsPattern::Specific(vec![Method::GET]);
let re = r"^GET https://obeli\.sk/docs/$";
assert!(cfg("obeli.sk", get(), None).covers(&cfg("obeli.sk", get(), Some(re))));
assert!(cfg("obeli.sk", get(), Some(re)).covers(&cfg("obeli.sk", get(), Some(re))));
assert!(!cfg("obeli.sk", get(), Some(re)).covers(&cfg("obeli.sk", get(), None)));
assert!(!cfg("obeli.sk", get(), Some(r"^GET .*$")).covers(&cfg(
"obeli.sk",
get(),
Some(re)
)));
}
fn empty_body() -> wasmtime_wasi_http::p2::body::HyperOutgoingBody {
http_body_util::combinators::UnsyncBoxBody::new(http_body_util::BodyExt::map_err(
http_body_util::Empty::<hyper::body::Bytes>::new(),
|_| unreachable!(),
))
}
#[test]
fn parse_host_pattern_bare_hostname() {
let p = HostPattern::parse("api.openai.com").unwrap();
assert_eq!(p.scheme, SchemePattern::Https);
assert_eq!(p.host_pattern, "api.openai.com");
assert_eq!(p.port, PortPattern::Default);
assert!(p.matches("https", "api.openai.com", 443, &Method::GET));
assert!(!p.matches("http", "api.openai.com", 80, &Method::GET));
}
#[test]
fn parse_host_pattern_with_scheme_and_port() {
let p = HostPattern::parse("http://localhost:8080").unwrap();
assert_eq!(p.scheme, SchemePattern::Http);
assert_eq!(p.host_pattern, "localhost");
assert_eq!(p.port, PortPattern::Specific(8080));
assert!(p.matches("http", "localhost", 8080, &Method::GET));
assert!(!p.matches("https", "localhost", 8080, &Method::GET));
}
#[test]
fn parse_host_pattern_http_default_port() {
let p = HostPattern::parse("http://example.com").unwrap();
assert_eq!(p.scheme, SchemePattern::Http);
assert_eq!(p.host_pattern, "example.com");
assert_eq!(p.port, PortPattern::Default);
assert!(p.matches("http", "example.com", 80, &Method::GET));
assert!(!p.matches("http", "example.com", 8080, &Method::GET));
}
#[test]
fn parse_host_pattern_wildcard_prefix() {
let p = HostPattern::parse("*.example.com").unwrap();
assert!(p.matches("https", "api.example.com", 443, &Method::GET));
assert!(p.matches("https", "foo.bar.example.com", 443, &Method::POST));
assert!(!p.matches("https", "example.com", 443, &Method::GET));
}
#[test]
fn parse_host_pattern_wildcard_suffix() {
let p = HostPattern::parse("192.168.1.*").unwrap();
assert!(p.matches("https", "192.168.1.100", 443, &Method::GET));
assert!(!p.matches("https", "192.168.2.100", 443, &Method::GET));
}
#[test]
fn parse_host_pattern_wildcard_all_https() {
let p = HostPattern::parse("*").unwrap();
assert!(p.matches("https", "anything.com", 443, &Method::GET));
assert!(!p.matches("http", "anything.com", 80, &Method::GET));
}
#[test]
fn parse_host_pattern_wildcard_http() {
let p = HostPattern::parse("http://*").unwrap();
assert!(!p.matches("https", "anything.com", 443, &Method::GET));
assert!(p.matches("http", "anything.com", 80, &Method::GET));
}
#[test]
fn parse_host_pattern_wildcard_middle_rejected() {
assert!(HostPattern::parse("foo.*.com").is_err());
}
#[test]
fn parse_host_pattern_trailing_slash_rejected() {
assert!(HostPattern::parse("http://localhost:8080/").is_err());
assert!(HostPattern::parse("https://api.example.com/v1").is_err());
assert!(HostPattern::parse("example.com/path").is_err());
}
#[test]
fn parse_host_pattern_https_non_default_port() {
let p = HostPattern::parse("internal.corp.com:8443").unwrap();
assert_eq!(p.scheme, SchemePattern::Https);
assert_eq!(p.host_pattern, "internal.corp.com");
assert_eq!(p.port, PortPattern::Specific(8443));
}
#[test]
fn host_pattern_method_restriction() {
let p = HostPattern::parse_with_methods(
"api.example.com",
MethodsPattern::Specific(vec![Method::GET, Method::HEAD]),
)
.unwrap();
assert!(p.matches("https", "api.example.com", 443, &Method::GET));
assert!(p.matches("https", "api.example.com", 443, &Method::HEAD));
assert!(!p.matches("https", "api.example.com", 443, &Method::POST));
assert!(!p.matches("https", "api.example.com", 443, &Method::DELETE));
}
#[test]
fn host_pattern_all_methods_allows_all() {
let p = HostPattern::parse("api.example.com").unwrap();
assert_eq!(p.methods, MethodsPattern::AllMethods);
assert!(p.matches("https", "api.example.com", 443, &Method::GET));
assert!(p.matches("https", "api.example.com", 443, &Method::POST));
assert!(p.matches("https", "api.example.com", 443, &Method::DELETE));
assert!(p.matches("https", "api.example.com", 443, &Method::PUT));
}
#[test]
fn host_pattern_empty_methods_matches_nothing() {
let p =
HostPattern::parse_with_methods("api.example.com", MethodsPattern::Specific(vec![]))
.unwrap();
assert!(!p.matches("https", "api.example.com", 443, &Method::GET));
assert!(!p.matches("https", "api.example.com", 443, &Method::POST));
assert!(!p.matches("https", "api.example.com", 443, &Method::DELETE));
}
#[test]
fn display_host_pattern_with_methods() {
let p = HostPattern::parse_with_methods(
"api.example.com",
MethodsPattern::Specific(vec![Method::GET, Method::POST]),
)
.unwrap();
assert_eq!(p.to_string(), "https://api.example.com [GET, POST]");
}
#[test]
fn parse_host_pattern_any_scheme_default_ports() {
let p = HostPattern::parse("*://*").unwrap();
assert_eq!(p.scheme, SchemePattern::Any);
assert_eq!(p.host_pattern, "*");
assert_eq!(p.port, PortPattern::Default);
assert!(p.matches("http", "foo.com", 80, &Method::GET));
assert!(p.matches("https", "foo.com", 443, &Method::GET));
assert!(!p.matches("http", "foo.com", 8080, &Method::GET));
assert!(!p.matches("https", "foo.com", 8443, &Method::GET));
}
#[test]
fn parse_host_pattern_any_scheme_any_port() {
let p = HostPattern::parse("*://*:*").unwrap();
assert_eq!(p.scheme, SchemePattern::Any);
assert_eq!(p.host_pattern, "*");
assert_eq!(p.port, PortPattern::Any);
assert!(p.matches("http", "foo.com", 80, &Method::GET));
assert!(p.matches("https", "foo.com", 443, &Method::GET));
assert!(p.matches("http", "foo.com", 8080, &Method::GET));
assert!(p.matches("https", "foo.com", 8443, &Method::GET));
assert!(p.matches("http", "localhost", 3000, &Method::POST));
}
#[test]
fn parse_host_pattern_any_port_specific_scheme() {
let p = HostPattern::parse("http://localhost:*").unwrap();
assert_eq!(p.scheme, SchemePattern::Http);
assert_eq!(p.host_pattern, "localhost");
assert_eq!(p.port, PortPattern::Any);
assert!(p.matches("http", "localhost", 80, &Method::GET));
assert!(p.matches("http", "localhost", 8080, &Method::GET));
assert!(p.matches("http", "localhost", 3000, &Method::GET));
assert!(!p.matches("https", "localhost", 443, &Method::GET));
assert!(!p.matches("http", "other.com", 80, &Method::GET));
}
#[test]
fn parse_host_pattern_wildcard_host_any_port() {
let p = HostPattern::parse("http://192.*:*").unwrap();
assert_eq!(p.scheme, SchemePattern::Http);
assert_eq!(p.host_pattern, "192.*");
assert_eq!(p.port, PortPattern::Any);
assert!(p.matches("http", "192.168.1.1", 80, &Method::GET));
assert!(p.matches("http", "192.168.1.1", 8080, &Method::GET));
assert!(p.matches("http", "192.0.0.1", 3000, &Method::POST));
assert!(!p.matches("https", "192.168.1.1", 443, &Method::GET));
assert!(!p.matches("http", "10.0.0.1", 80, &Method::GET));
}
#[test]
fn display_host_pattern_any_scheme() {
let p = HostPattern::parse("*://*").unwrap();
assert_eq!(p.to_string(), "*://*");
let p = HostPattern::parse("*://*:*").unwrap();
assert_eq!(p.to_string(), "*://*:*");
let p = HostPattern::parse("http://localhost:*").unwrap();
assert_eq!(p.to_string(), "http://localhost:*");
}
#[test]
fn display_host_pattern_empty_methods() {
let p =
HostPattern::parse_with_methods("api.example.com", MethodsPattern::Specific(vec![]))
.unwrap();
assert_eq!(p.to_string(), "https://api.example.com [NONE]");
}
#[test]
fn request_match_input_omits_query_params() {
let uri: Uri = "https://api.example.com/v1/items?token=secret"
.parse()
.unwrap();
assert_eq!(
request_match_input(&uri, &Method::GET).as_deref(),
Some("GET https://api.example.com/v1/items")
);
}
#[test]
fn request_match_input_preserves_non_default_port() {
let uri: Uri = "https://api.example.com:8443/v1/items?token=secret"
.parse()
.unwrap();
assert_eq!(
request_match_input(&uri, &Method::GET).as_deref(),
Some("GET https://api.example.com:8443/v1/items")
);
}
#[test]
fn request_url_regex_restricts_query_stripped_method_url() {
let policy = AllowedHostPolicy {
pattern: HostPattern::parse_with_methods(
"api.example.com",
MethodsPattern::Specific(vec![Method::GET]),
)
.unwrap(),
request_url_regex: Some(Regex::new(r"^GET https://api\.example\.com/v1/").unwrap()),
secrets: Vec::new(),
};
assert!(policy.matches_request(
"https",
"api.example.com",
443,
&Method::GET,
"GET https://api.example.com/v1/items"
));
assert!(!policy.matches_request(
"https",
"api.example.com",
443,
&Method::GET,
"GET https://api.example.com/v2/items"
));
assert!(!policy.matches_request(
"https",
"api.example.com",
443,
&Method::POST,
"POST https://api.example.com/v1/items"
));
}
#[test]
fn params_replace_only_query_parameter_values() {
const PLACEHOLDER: &str = "OBELISK_SECRET_TEST_PLACEHOLDER";
let policy = HttpRequestPolicy {
hosts: vec![AllowedHostPolicy {
pattern: HostPattern::parse("api.example.com").unwrap(),
request_url_regex: None,
secrets: vec![PlaceholderSecret {
name: "SECRET".to_string(),
placeholder: PLACEHOLDER.to_string(),
real_value: SecretString::from("real/value? with space"),
replace_in: hashbrown::HashSet::from([ReplacementLocation::Params]),
}],
}],
global_allowlist: None,
};
let uri = format!(
"https://api.example.com/{PLACEHOLDER}?{PLACEHOLDER}=unchanged&token=Bearer-{PLACEHOLDER}"
);
let mut request = hyper::Request::builder()
.uri(uri)
.body(empty_body())
.unwrap();
policy.apply(&mut request).unwrap();
assert_eq!(request.uri().path(), format!("/{PLACEHOLDER}"));
let params = url::form_urlencoded::parse(request.uri().query().unwrap().as_bytes())
.into_owned()
.collect::<Vec<_>>();
assert_eq!(
params,
vec![
(PLACEHOLDER.to_string(), "unchanged".to_string()),
(
"token".to_string(),
"Bearer-real/value? with space".to_string()
),
]
);
}
#[test]
fn overlapping_rules_union_secret_replacement_per_target() {
let headers_only = |name: &str, ph: &str, real: &str| PlaceholderSecret {
name: name.to_string(),
placeholder: ph.to_string(),
real_value: SecretString::from(real),
replace_in: hashbrown::HashSet::from([ReplacementLocation::Headers]),
};
let params_only = |name: &str, ph: &str, real: &str| PlaceholderSecret {
name: name.to_string(),
placeholder: ph.to_string(),
real_value: SecretString::from(real),
replace_in: hashbrown::HashSet::from([ReplacementLocation::Params]),
};
let host = || AllowedHostPolicy {
pattern: HostPattern::parse("api.example.com").unwrap(),
request_url_regex: None,
secrets: Vec::new(),
};
let policy = HttpRequestPolicy {
hosts: vec![
AllowedHostPolicy {
secrets: vec![
headers_only("S1", "PH_S1", "REAL_1"),
headers_only("S2", "PH_S2", "REAL_2"),
],
..host()
},
AllowedHostPolicy {
secrets: vec![
params_only("S2", "PH_S2", "REAL_2"),
params_only("S3", "PH_S3", "REAL_3"),
],
..host()
},
],
global_allowlist: None,
};
let mut request = hyper::Request::builder()
.uri("https://api.example.com/path?a=PH_S1&b=PH_S2&c=PH_S3")
.header("h-s1", "v-PH_S1")
.header("h-s2", "v-PH_S2")
.header("h-s3", "v-PH_S3")
.body(empty_body())
.unwrap();
policy.apply(&mut request).unwrap();
assert_eq!(request.headers().get("h-s1").unwrap(), "v-REAL_1");
assert_eq!(request.headers().get("h-s2").unwrap(), "v-REAL_2");
assert_eq!(request.headers().get("h-s3").unwrap(), "v-PH_S3");
let params = url::form_urlencoded::parse(request.uri().query().unwrap().as_bytes())
.into_owned()
.collect::<Vec<_>>();
assert_eq!(
params,
vec![
("a".to_string(), "PH_S1".to_string()),
("b".to_string(), "REAL_2".to_string()),
("c".to_string(), "REAL_3".to_string()),
]
);
}
#[test]
fn global_allowlist_intersects_secret_and_replacement_target() {
let secret =
|name: &str, ph: &str, real: &str, locs: &[ReplacementLocation]| PlaceholderSecret {
name: name.to_string(),
placeholder: ph.to_string(),
real_value: SecretString::from(real),
replace_in: locs.iter().copied().collect(),
};
let host = |secrets: Vec<PlaceholderSecret>| AllowedHostPolicy {
pattern: HostPattern::parse("api.example.com").unwrap(),
request_url_regex: None,
secrets,
};
let policy = HttpRequestPolicy {
hosts: vec![host(vec![
secret(
"S1",
"PH_S1",
"REAL_1",
&[ReplacementLocation::Headers, ReplacementLocation::Params],
),
secret("S2", "PH_S2", "REAL_2", &[ReplacementLocation::Headers]),
])],
global_allowlist: Some(vec![host(vec![secret(
"S1",
"",
"",
&[ReplacementLocation::Headers],
)])]),
};
let mut request = hyper::Request::builder()
.uri("https://api.example.com/path?a=PH_S1")
.header("h1", "v-PH_S1")
.header("h2", "v-PH_S2")
.body(empty_body())
.unwrap();
policy.apply(&mut request).unwrap();
assert_eq!(request.headers().get("h1").unwrap(), "v-REAL_1");
assert_eq!(request.headers().get("h2").unwrap(), "v-PH_S2");
let params = url::form_urlencoded::parse(request.uri().query().unwrap().as_bytes())
.into_owned()
.collect::<Vec<_>>();
assert_eq!(params, vec![("a".to_string(), "PH_S1".to_string())]);
}
#[test]
fn global_allowlist_denies_destination_component_allows() {
let host = |name: &str| AllowedHostPolicy {
pattern: HostPattern::parse(name).unwrap(),
request_url_regex: None,
secrets: Vec::new(),
};
let policy = HttpRequestPolicy {
hosts: vec![host("api.example.com")],
global_allowlist: Some(vec![host("other.example.com")]),
};
let mut request = hyper::Request::builder()
.uri("https://api.example.com/path")
.body(empty_body())
.unwrap();
let err = policy.apply(&mut request).unwrap_err();
assert!(matches!(err, PolicyError::RequestDenied { .. }));
}
#[test]
fn global_allowlist_scopes_authorization_per_matching_host() {
const PLACEHOLDER: &str = "PH_S";
let header_secret = PlaceholderSecret {
name: "S".to_string(),
placeholder: PLACEHOLDER.to_string(),
real_value: SecretString::from("REAL_S"),
replace_in: hashbrown::HashSet::from([ReplacementLocation::Headers]),
};
let host_and_secrets_fn = |name: &str, secrets: Vec<PlaceholderSecret>| AllowedHostPolicy {
pattern: HostPattern::parse(name).unwrap(),
request_url_regex: None,
secrets,
};
let policy = HttpRequestPolicy {
hosts: vec![
host_and_secrets_fn("api.example.com", vec![header_secret.clone()]),
host_and_secrets_fn("other.example.com", vec![header_secret.clone()]),
],
global_allowlist: Some(vec![
host_and_secrets_fn("api.example.com", vec![header_secret.clone()]),
host_and_secrets_fn("other.example.com", Vec::new()), ]),
};
let mut req_a = hyper::Request::builder()
.uri("https://api.example.com/path")
.header("h", format!("v-{PLACEHOLDER}"))
.body(empty_body())
.unwrap();
policy.apply(&mut req_a).unwrap();
assert_eq!(req_a.headers().get("h").unwrap(), "v-REAL_S");
let mut req_b = hyper::Request::builder()
.uri("https://other.example.com/path")
.header("h", format!("v-{PLACEHOLDER}"))
.body(empty_body())
.unwrap();
policy.apply(&mut req_b).unwrap();
assert_eq!(req_b.headers().get("h").unwrap(), "v-PH_S");
}
#[test]
fn empty_global_allowlist_denies_every_destination() {
let policy = HttpRequestPolicy {
hosts: vec![AllowedHostPolicy {
pattern: HostPattern::parse("*://*:*").unwrap(),
request_url_regex: None,
secrets: Vec::new(),
}],
global_allowlist: Some(Vec::new()),
};
let mut request = hyper::Request::builder()
.uri("https://api.example.com/path")
.body(empty_body())
.unwrap();
let err = policy.apply(&mut request).unwrap_err();
assert!(matches!(
err,
PolicyError::RequestDenied {
denied_by: PolicyLayer::GlobalAllowlist,
..
}
));
}
#[test]
fn generate_placeholder_format() {
let p = generate_placeholder();
assert!(p.starts_with("OBELISK_SECRET_"));
assert_eq!(p.len(), 15 + 64); }
#[test]
fn generate_placeholder_unique() {
let p1 = generate_placeholder();
let p2 = generate_placeholder();
assert_ne!(p1, p2);
}
#[test]
fn display_host_pattern() {
let p = HostPattern::parse("api.openai.com").unwrap();
assert_eq!(p.to_string(), "https://api.openai.com");
let p = HostPattern::parse("http://localhost:8080").unwrap();
assert_eq!(p.to_string(), "http://localhost:8080");
let p = HostPattern::parse("internal.corp.com:8443").unwrap();
assert_eq!(p.to_string(), "https://internal.corp.com:8443");
}
#[test]
fn test_is_text_content_type() {
assert!(is_text_content_type("application/json"));
assert!(is_text_content_type("application/json; charset=utf-8"));
assert!(is_text_content_type("application/vnd.api+json"));
assert!(is_text_content_type("text/plain"));
assert!(is_text_content_type("text/html"));
assert!(is_text_content_type("application/x-www-form-urlencoded"));
assert!(!is_text_content_type("application/octet-stream"));
assert!(!is_text_content_type("image/png"));
}
}