mod route;
use serde::{Deserialize, Serialize};
pub use self::route::{MAX_ROUTES, METHOD_OVERRIDE_HEADERS, PathRefusal, RequestPath, Route};
use crate::host::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
use crate::policy::PolicyError;
pub const SECRET_RULE_PREFIX: &str = "secrets.";
const RESERVED_HEADERS: &[&str] = &[
"host",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
];
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Secret {
pub id: String,
pub host: String,
pub header: String,
pub source: Source,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub plain_http: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls: Option<Tls>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tls {
Terminate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Permit {
Unrestricted,
Rule(String),
Refused(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub enum Source {
File(String),
Env(String),
Keychain {
service: String,
account: String,
},
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secret")
.field("id", &self.id)
.finish_non_exhaustive()
}
}
impl Secret {
#[must_use]
pub fn placeholder(&self) -> String {
format!("moat-secret:{}:placeholder", self.id)
}
#[must_use]
pub fn plain_http_port(&self) -> u16 {
self.port.unwrap_or(80)
}
#[must_use]
pub fn is_loopback(&self) -> bool {
let host = self.host.as_str();
host == "localhost"
|| host.ends_with(".localhost")
|| host
.parse::<core::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[must_use]
pub fn terminates(&self) -> bool {
self.tls == Some(Tls::Terminate)
}
#[must_use]
pub fn permit(&self, method: &str, target: &str, header_names: &[&str]) -> Permit {
if self.allow.is_empty() {
return Permit::Unrestricted;
}
let refused = |why: String| {
Permit::Refused(format!(
"{method} request to {} is refused for secret `{}`: {why}",
self.host, self.id
))
};
let path = match RequestPath::parse(target) {
Ok(path) => path,
Err(why) => return refused(why.to_string()),
};
if let Some(name) = header_names.iter().find(|name| {
METHOD_OVERRIDE_HEADERS
.iter()
.any(|h| name.eq_ignore_ascii_case(h))
}) {
return refused(format!("its `{name}` header asks for another method"));
}
if path.overrides_method() {
return refused("its query names a `_method` parameter".to_owned());
}
self.allow
.iter()
.find(|rule| Route::parse(rule).is_ok_and(|r| r.matches(method, &path)))
.map_or_else(
|| refused("it matches none of the secret's `allow` rules".to_owned()),
|rule| Permit::Rule(rule.clone()),
)
}
}
pub(crate) fn check(secrets: &[Secret]) -> Result<(), PolicyError> {
for (i, secret) in secrets.iter().enumerate() {
let fail = |problem: String| PolicyError::Rule {
rule: format!("{SECRET_RULE_PREFIX}{}", secret.id),
problem,
};
check_one(secret).map_err(fail)?;
if let Some(earlier) = secrets[..i].iter().find(|s| s.id == secret.id) {
return Err(PolicyError::DuplicateId(format!(
"{SECRET_RULE_PREFIX}{}",
earlier.id
)));
}
if let Some(earlier) = secrets[..i]
.iter()
.find(|s| s.host == secret.host && s.header.eq_ignore_ascii_case(&secret.header))
{
return Err(fail(format!(
"secret `{}` already sets {} for {}",
earlier.id, secret.header, secret.host
)));
}
}
Ok(())
}
fn check_one(secret: &Secret) -> Result<(), String> {
let id = &secret.id;
if id.is_empty()
|| id.len() > 64
|| !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err("id must be 1 to 64 lowercase letters, digits or `-`".into());
}
if !is_host(&secret.host) {
return Err(format!(
"host `{}` must be one lowercase DNS name or IPv4 address, without port or pattern",
secret.host
));
}
let header = &secret.header;
if header.is_empty() || !header.bytes().all(is_token_byte) {
return Err(format!("header `{header}` is not an HTTP header name"));
}
if RESERVED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) {
return Err(format!(
"header `{header}` frames the request or connection; it cannot carry a secret"
));
}
match secret.port {
Some(0) => return Err("port must be 1 to 65535".into()),
Some(port) if !secret.plain_http => {
return Err(format!(
"port {port} names where plain-HTTP requests get the value; it needs \
`plain_http: true`"
));
}
_ => {}
}
check_transport(secret)?;
match &secret.source {
Source::File(path) if !(crate::paths::is_absolute(path) || path.starts_with("~/")) => Err(
format!("source file `{path}` must be absolute or start with `~/`"),
),
Source::Env(name) if !is_env_name(name) => {
Err(format!("source env `{name}` is not a variable name"))
}
Source::Keychain { service, account }
if [service, account]
.iter()
.any(|s| s.is_empty() || s.chars().any(char::is_control)) =>
{
Err("source keychain needs a service and an account without control characters".into())
}
_ => Ok(()),
}
}
fn check_transport(secret: &Secret) -> Result<(), String> {
if secret.terminates() && secret.host.parse::<core::net::Ipv4Addr>().is_ok() {
return Err(format!(
"`tls: terminate` needs a DNS name; {} is an address, which the proxy's \
certificate authority may not sign for",
secret.host
));
}
if secret.allow.is_empty() {
return Ok(());
}
if !secret.terminates() && !secret.plain_http {
return Err(
"`allow` rules need `tls: terminate` or `plain_http: true`: elsewhere the proxy \
cannot read the request"
.into(),
);
}
if secret.allow.len() > MAX_ROUTES {
return Err(format!("`allow` lists more than {MAX_ROUTES} rules"));
}
secret
.allow
.iter()
.try_for_each(|rule| Route::parse(rule).map(drop))
}
fn is_host(host: &str) -> bool {
host.len() <= MAX_DNS_NAME_LEN
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= MAX_DNS_LABEL_LEN
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
})
}
fn is_token_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
}
fn is_env_name(name: &str) -> bool {
let mut bytes = name.bytes();
bytes
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
&& bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
#[cfg(test)]
mod tests;