use std::collections::BTreeSet;
use async_trait::async_trait;
use regex::Regex;
use tracing::info;
use super::policy::{Check, StageSet, Verdict};
use super::{
IdentifierContext, ListVerdict, SUBJECT_ONLY_TYPES, check_lists, compile_matchers,
default_identifier_types,
};
#[derive(Debug, Clone)]
pub struct Settings {
pub allowed_types: Vec<String>,
pub allow: Vec<String>,
pub deny: Vec<String>,
pub allow_regex: Vec<String>,
pub deny_regex: Vec<String>,
pub allow_wildcards: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
allowed_types: default_identifier_types(),
allow: Vec::new(),
deny: Vec::new(),
allow_regex: Vec::new(),
deny_regex: Vec::new(),
allow_wildcards: false,
}
}
}
#[derive(Debug)]
pub struct IdentifierList {
allowed_types: BTreeSet<String>,
allow: Vec<Regex>,
deny: Vec<Regex>,
allow_wildcards: bool,
}
impl IdentifierList {
pub fn from_settings(name: &str, settings: &Settings) -> anyhow::Result<Self> {
let check = Self {
allowed_types: settings
.allowed_types
.iter()
.map(|typ| typ.to_ascii_lowercase())
.collect(),
allow: compile_matchers(&settings.allow, &settings.allow_regex, name, "allow")?,
deny: compile_matchers(&settings.deny, &settings.deny_regex, name, "deny")?,
allow_wildcards: settings.allow_wildcards,
};
info!(
event = "filter_identifiers_loaded",
outcome = "success",
check = name,
allowed_types = ?settings.allowed_types,
allow = check.allow.len(),
deny = check.deny.len(),
allow_wildcards = settings.allow_wildcards,
);
Ok(check)
}
fn decide(&self, context: &IdentifierContext<'_>) -> Verdict {
let stage = context.stage.as_str();
for identifier in context.identifiers {
let typ = identifier.typ.to_ascii_lowercase();
if !self.allowed_types.contains(&typ) {
return Verdict::Fail(format!(
"{stage} requests a {} identifier, which is not permitted",
identifier.typ
));
}
let value = &identifier.value;
if !self.allow_wildcards && typ == "dns" && value.starts_with("*.") {
return Verdict::Fail(format!(
"{stage} identifier {value} is a wildcard, which policy does not permit"
));
}
let allow: &[Regex] = if SUBJECT_ONLY_TYPES.contains(&typ.as_str()) {
&[]
} else {
&self.allow
};
match check_lists(allow, &self.deny, |pattern| pattern.is_match(value)) {
ListVerdict::Permitted => {}
ListVerdict::Denied => {
return Verdict::Fail(format!(
"{stage} identifier {value} is denied by policy"
));
}
ListVerdict::NotAllowed => {
return Verdict::Fail(format!(
"{stage} identifier {value} is not permitted by policy"
));
}
}
}
Verdict::Pass
}
}
#[async_trait]
impl Check for IdentifierList {
fn kind(&self) -> &'static str {
"identifiers"
}
fn stages(&self) -> StageSet {
StageSet::identifiers_only()
}
async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Verdict {
self.decide(context)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter::IdentifierStage;
use crate::sqlite::order::Identifier;
use crate::testutil::identifiers as ids;
fn globs(allow: &[&str], deny: &[&str]) -> Settings {
Settings {
allow: allow.iter().map(std::string::ToString::to_string).collect(),
deny: deny.iter().map(std::string::ToString::to_string).collect(),
..Settings::default()
}
}
fn regexes(allow: &[&str], deny: &[&str]) -> Settings {
Settings {
allow_regex: allow.iter().map(std::string::ToString::to_string).collect(),
deny_regex: deny.iter().map(std::string::ToString::to_string).collect(),
..Settings::default()
}
}
fn built(settings: &Settings) -> IdentifierList {
IdentifierList::from_settings("names", settings).unwrap()
}
async fn verdict_for(
check: &IdentifierList,
stage: IdentifierStage,
identifiers: &[Identifier],
) -> Verdict {
check
.check_identifiers(&IdentifierContext {
client_ip: None,
account_id: "acct-1",
stage,
identifiers,
eab: None,
})
.await
}
fn assert_failed(verdict: Verdict, needle: &str) {
match verdict {
Verdict::Fail(detail) => {
assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[tokio::test]
async fn a_glob_star_matches_exactly_one_label() {
let check = built(&globs(&["*.example.com"], &[]));
assert_eq!(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "a.example.com")])
)
.await,
Verdict::Pass
);
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "a.b.example.com")]),
)
.await,
"not permitted by policy",
);
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "example.com")]),
)
.await,
"not permitted by policy",
);
}
#[tokio::test]
async fn listing_the_bare_name_beside_the_glob_covers_both() {
let check = built(&globs(&["*.example.com", "example.com"], &[]));
for name in ["a.example.com", "example.com"] {
assert_eq!(
verdict_for(&check, IdentifierStage::NewOrder, &ids(&[("dns", name)])).await,
Verdict::Pass,
"{name}"
);
}
}
#[tokio::test]
async fn a_glob_ignores_case_and_a_trailing_dot_is_not_special() {
let check = built(&globs(&["*.example.com"], &[]));
assert_eq!(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "A.Example.COM")])
)
.await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_glob_escapes_every_other_metacharacter() {
let check = built(&globs(&["a.example.com"], &[]));
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "axexample.com")]),
)
.await,
"not permitted",
);
}
#[tokio::test]
async fn a_glob_deny_wins_over_a_glob_allow() {
let check = built(&globs(&["*.example.com"], &["secret.example.com"]));
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "secret.example.com")]),
)
.await,
"is denied by policy",
);
}
#[tokio::test]
async fn globs_and_regexes_are_unioned_on_both_sides() {
let settings = Settings {
allow: vec!["*.example.com".to_string()],
allow_regex: vec![r"host\d+\.internal".to_string()],
deny: vec!["secret.example.com".to_string()],
deny_regex: vec![r"host666\.internal".to_string()],
..Settings::default()
};
let check = built(&settings);
for permitted in ["a.example.com", "host12.internal"] {
assert_eq!(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", permitted)])
)
.await,
Verdict::Pass,
"{permitted}"
);
}
for refused in ["secret.example.com", "host666.internal"] {
assert_failed(
verdict_for(&check, IdentifierStage::NewOrder, &ids(&[("dns", refused)])).await,
"is denied by policy",
);
}
}
#[tokio::test]
async fn a_glob_allow_skips_the_common_name_but_a_glob_deny_reaches_it() {
let check = built(&globs(&["*.example.com"], &["secret.example.com"]));
assert_eq!(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "ok.example.com"), ("cn", "rcgen self signed cert")])
)
.await,
Verdict::Pass
);
assert_failed(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "ok.example.com"), ("cn", "secret.example.com")]),
)
.await,
"is denied by policy",
);
}
#[tokio::test]
async fn an_empty_allow_list_permits_any_allowed_type() {
let check = built(&Settings::default());
let identifiers = ids(&[("dns", "anything.example.com"), ("cn", "other.test")]);
assert_eq!(
verdict_for(&check, IdentifierStage::NewOrder, &identifiers).await,
Verdict::Pass
);
}
#[tokio::test]
async fn the_allow_list_refuses_everything_else() {
let check = built(®exes(&[r".*\.example\.com"], &[]));
assert_eq!(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "a.example.com")])
)
.await,
Verdict::Pass
);
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "a.evil.net")]),
)
.await,
"not permitted by policy",
);
}
#[tokio::test]
async fn deny_wins_over_allow() {
let check = built(®exes(&[r".*\.example\.com"], &[r"secret\..*"]));
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "secret.example.com")]),
)
.await,
"is denied by policy",
);
}
#[tokio::test]
async fn patterns_are_anchored_so_a_suffix_cannot_bypass_them() {
let check = built(®exes(&[r"example\.com"], &[]));
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "example.com.evil.net")]),
)
.await,
"not permitted",
);
}
#[tokio::test]
async fn matching_ignores_case() {
let check = built(®exes(&[], &[r"secret\.example\.com"]));
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "SECRET.Example.COM")]),
)
.await,
"is denied",
);
}
#[tokio::test]
async fn an_ip_san_is_refused_by_the_default_allowed_types() {
let check = built(&Settings::default());
assert_failed(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "ok.example.com"), ("ip", "10.0.0.1")]),
)
.await,
"requests a ip identifier",
);
}
#[tokio::test]
async fn email_and_uri_sans_are_refused_too() {
let check = built(&Settings::default());
for identifier in [("email", "a@example.com"), ("uri", "https://example.com")] {
assert_failed(
verdict_for(&check, IdentifierStage::Csr, &ids(&[identifier])).await,
"is not permitted",
);
}
}
#[tokio::test]
async fn an_operator_can_opt_into_ip_identifiers() {
let settings = Settings {
allowed_types: vec!["dns".to_string(), "ip".to_string()],
allow_regex: vec![r"10\..*".to_string(), r".*\.example\.com".to_string()],
..Settings::default()
};
let check = built(&settings);
assert_eq!(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("ip", "10.0.0.1"), ("dns", "a.example.com")])
)
.await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_wildcard_is_refused_by_default_even_when_the_lists_would_permit_it() {
let check = built(®exes(&[], &[r"secret\.example\.com"]));
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "*.example.com")]),
)
.await,
"is a wildcard",
);
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "secret.example.com")]),
)
.await,
"denied by policy",
);
}
#[tokio::test]
async fn an_operator_can_opt_into_wildcards() {
let settings = Settings {
allow_regex: vec![r"\*\.example\.com".to_string()],
deny_regex: vec![r"\*\.internal\.example".to_string()],
allow_wildcards: true,
..Settings::default()
};
let check = built(&settings);
assert_eq!(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "*.example.com")])
)
.await,
Verdict::Pass
);
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "*.internal.example")]),
)
.await,
"denied by policy",
);
}
#[tokio::test]
async fn a_glob_can_name_the_wildcard_form_itself() {
let settings = Settings {
allow: vec!["*.example.com".to_string()],
allow_wildcards: true,
..Settings::default()
};
let check = built(&settings);
assert_eq!(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "*.example.com")])
)
.await,
Verdict::Pass
);
}
#[tokio::test]
async fn the_wildcard_check_does_not_reach_the_common_name() {
let check = built(&Settings::default());
assert_eq!(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "example.com"), ("cn", "*.example.com")])
)
.await,
Verdict::Pass
);
}
#[tokio::test]
async fn deny_reaches_the_common_name() {
let check = built(®exes(&[], &[r"secret\.example\.com"]));
assert_failed(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "ok.example.com"), ("cn", "secret.example.com")]),
)
.await,
"is denied by policy",
);
}
#[tokio::test]
async fn allow_does_not_constrain_the_common_name() {
let check = built(®exes(&[r".*\.example\.com"], &[]));
assert_eq!(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "ok.example.com"), ("cn", "rcgen self signed cert")])
)
.await,
Verdict::Pass
);
}
#[tokio::test]
async fn allow_still_constrains_the_sans() {
let check = built(®exes(&[r".*\.example\.com"], &[]));
assert_failed(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "evil.net"), ("cn", "ok.example.com")]),
)
.await,
"evil.net is not permitted",
);
}
#[tokio::test]
async fn the_common_name_can_be_disallowed_by_type() {
let settings = Settings {
allowed_types: vec!["dns".to_string()],
..Settings::default()
};
let check = built(&settings);
assert_failed(
verdict_for(&check, IdentifierStage::Csr, &ids(&[("cn", "anything")])).await,
"requests a cn identifier",
);
}
#[tokio::test]
async fn the_stage_appears_in_the_detail() {
let check = built(®exes(&[], &[r".*"]));
assert_failed(
verdict_for(
&check,
IdentifierStage::NewOrder,
&ids(&[("dns", "a.example.com")]),
)
.await,
"newOrder identifier",
);
assert_failed(
verdict_for(
&check,
IdentifierStage::Csr,
&ids(&[("dns", "a.example.com")]),
)
.await,
"CSR identifier",
);
}
#[tokio::test]
async fn an_empty_identifier_list_is_allowed() {
let check = built(®exes(&["nothing"], &[]));
assert_eq!(
verdict_for(&check, IdentifierStage::Csr, &[]).await,
Verdict::Pass
);
}
#[test]
fn a_bad_pattern_is_a_startup_error() {
let error = IdentifierList::from_settings("names", ®exes(&[], &["[unclosed"]))
.unwrap_err()
.to_string();
assert!(error.contains("filter.check.names.deny_regex"), "{error}");
}
#[test]
fn reports_its_type_and_stages() {
let check = built(&Settings::default());
assert_eq!(check.kind(), "identifiers");
assert_eq!(check.stages(), StageSet::identifiers_only());
}
#[test]
fn the_default_settings_permit_dns_names_and_common_names() {
assert_eq!(Settings::default().allowed_types, vec!["dns", "cn"]);
assert!(!Settings::default().allow_wildcards);
}
}