acme_proxy/filter/mod.rs
1//! Request filtering: named checks, boolean rules over them, and the machinery
2//! that turns a request into one answer.
3//!
4//! Filters answer a different question from [`challenge`](crate::challenge):
5//! *who* may ask, rather than whether they control the name. Both matter, and
6//! when `challenge.bypass` is on, filtering is the **only** thing deciding who
7//! may obtain a certificate, because a triggered challenge is then accepted
8//! with no network check at all.
9//!
10//! ## The shape
11//!
12//! A [`Check`] is one named question — "is this address in the management
13//! network?", "does the inventory say this address owns this name?" — declared
14//! as `[filter.check.<name>]` with a `type`. A rule is a boolean expression
15//! over check names plus what a match means, declared as
16//! `[filter.rule.<name>]` and selected and ordered by `filter.rules`.
17//! [`FilterPolicy`] holds both and answers one request.
18//!
19//! ```toml
20//! [filter]
21//! rules = ["mgmt-bypass", "inventory-owned"]
22//!
23//! [filter.check.mgmt-net]
24//! type = "allowed_ip"
25//! allow = ["10.0.0.0/8"]
26//!
27//! [filter.check.inventory]
28//! type = "ipam"
29//!
30//! [filter.rule.mgmt-bypass]
31//! when = "mgmt-net"
32//! then = "allow"
33//!
34//! [filter.rule.inventory-owned]
35//! when = "inventory or mgmt-net"
36//! then = "allow"
37//! ```
38//!
39//! Everything is a named check: `custom` is `type = "custom"` like any other,
40//! with no separate selection list of its own, and two instances of one type
41//! are ordinary rather than impossible.
42//!
43//! ## Two hook points
44//!
45//! Some checks decide from the connection alone (is this IP allowed? does it
46//! have a valid PTR record?); others need the names being requested, which only
47//! the handlers know. Rather than two traits, [`Check`] has two methods, both
48//! defaulting to "pass":
49//!
50//! - [`check_connection`](Check::check_connection) runs in
51//! [`add_filter_middleware`](crate::middlewares::filter::add_filter_middleware)
52//! for every request.
53//! - [`check_identifiers`](Check::check_identifiers) runs at `newOrder` (the
54//! order's identifiers) and again at `finalize` (the CSR's subject
55//! alternative names and common name).
56//!
57//! Which rules run at which hook, and why the answer is an intersection rather
58//! than a union, is [`policy`]'s subject.
59//!
60//! ## Startup validation
61//!
62//! CIDRs, regexes and conditions are parsed once in [`from_config`], which
63//! returns an `anyhow::Error` the binary treats as fatal. A typo in a netmask
64//! is a configuration bug that should stop the server, not silently deny (or
65//! admit) traffic at runtime.
66
67use std::net::IpAddr;
68use std::sync::Arc;
69
70use axum::http::Method;
71use ipnet::IpNet;
72use regex::{Regex, RegexBuilder};
73
74use crate::config::FilterConfig;
75use crate::sqlite::order::Identifier;
76
77pub mod build;
78pub mod client_ip;
79pub mod custom;
80pub mod eab;
81pub mod explain;
82pub mod expr;
83pub mod identifiers;
84pub mod ip_allow;
85pub mod ipam;
86pub mod path;
87pub mod policy;
88pub mod reverse_dns;
89
90pub use client_ip::{ClientIp, ProxyPolicy};
91pub use eab::EabIdentity;
92pub use policy::{
93 Check, CheckSummary, Effect, FilterPolicy, Mode, Outcome, Rule, RuleSummary, Stage, StageSet,
94 Verdict,
95};
96
97/// Identifier types that are subject metadata rather than a name the
98/// certificate is issued *for*.
99///
100/// A common name is legacy subject metadata — RFC 6125 deprecated relying on
101/// it, and CSR generators routinely put a human label there (`rcgen`'s own
102/// default is the string `rcgen self signed cert`). Checks that decide what a
103/// certificate may be issued *for* therefore leave these alone: `identifiers`
104/// exempts them from its `allow` list, and `ipam` never asks an inventory to
105/// confirm one. Refusals still reach them — see
106/// [`identifiers`](self::identifiers) for why the two directions differ.
107pub(crate) const SUBJECT_ONLY_TYPES: &[&str] = &["cn"];
108
109/// What a [`Check`] knows about a request before it is dispatched.
110#[derive(Debug)]
111pub struct ConnectionContext<'a> {
112 /// The client address, per [`ProxyPolicy::resolve`]. `None` when the peer
113 /// address is unavailable — checks that need it must fail closed.
114 pub client_ip: Option<IpAddr>,
115 pub method: &'a Method,
116 pub path: &'a str,
117}
118
119/// Where in the flow a set of identifiers is being checked.
120///
121/// The same names are validated twice — once as the client's stated intent,
122/// once as what its CSR actually requests — and a check may want to treat the
123/// two differently. Both are the [`Stage::Identifiers`] stage as far as rule
124/// selection is concerned; this is the finer grain underneath it.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum IdentifierStage {
127 /// The `identifiers` array of a `newOrder` payload.
128 NewOrder,
129 /// The subject alternative names and common name of a finalize CSR.
130 Csr,
131}
132
133impl IdentifierStage {
134 /// Short label for logs and problem details.
135 #[must_use]
136 pub fn as_str(&self) -> &'static str {
137 match self {
138 Self::NewOrder => "newOrder",
139 Self::Csr => "CSR",
140 }
141 }
142}
143
144/// What a [`Check`] knows about the names a client wants certified.
145///
146/// Carries the account as well as the client address so a policy can bind
147/// names to either — for example "this network may only request names under
148/// its own subdomain".
149#[derive(Debug)]
150pub struct IdentifierContext<'a> {
151 /// The client address, as resolved by the middleware.
152 pub client_ip: Option<IpAddr>,
153 /// Id of the account making the request (already authenticated by the JWS
154 /// extractor and checked to own the order).
155 pub account_id: &'a str,
156 pub stage: IdentifierStage,
157 /// The requested names. At [`IdentifierStage::Csr`] these are projected
158 /// from the CSR, so `typ` may be `ip`, `email`, `uri`, `other` or `cn` as
159 /// well as `dns`.
160 pub identifiers: &'a [Identifier],
161 /// The external account binding this account registered under.
162 ///
163 /// Resolved by the caller **only when the policy contains an `eab` check**
164 /// ([`FilterPolicy::needs_eab`]), so a policy without one costs no lookup;
165 /// `None` therefore means either "no such check is configured" or "this
166 /// account used no EAB", and only [`eab::EabList`] is ever in a position
167 /// to tell the two apart — it is the sole reader.
168 pub eab: Option<EabIdentity>,
169}
170
171/// The client address, canonicalized, or the refusal its absence implies.
172///
173/// An address the server cannot see is not "absent from the deny list,
174/// therefore fine" — every check that reaches for one must fail closed. Having
175/// a single helper say so means a check added later gets that behaviour by
176/// asking for the address at all.
177///
178/// Deliberately [`Verdict::Fail`] and not [`Verdict::Undecided`]: the server
179/// saw a request it cannot attribute, which is a decision about the client
180/// rather than a failure to reach some authority. `Undecided` here would turn
181/// every such refusal into a 500.
182pub(crate) fn require_client_ip(client_ip: Option<IpAddr>) -> Result<IpAddr, Verdict> {
183 client_ip
184 .map(canonical)
185 .ok_or_else(|| Verdict::Fail("client address unavailable".to_string()))
186}
187
188/// Builds the configured policy. Called once at startup, so it may fail fast
189/// (the caller exits on error).
190///
191/// `dns` is [`crate::config::Config::dns`], not a field of `cfg`: the one check
192/// that resolves anything builds its own **cached** resolver from it, because a
193/// PTR lookup for an address that keeps connecting is exactly what a cache is
194/// for, while the shared resolver is deliberately uncached so a `dns-01` record
195/// published moments before a trigger is not defeated by a cached negative.
196///
197/// `eab_enabled` is the profile's `eab.enabled`: an `eab` check under an endpoint
198/// that does not require EAB could only ever refuse, which is a startup error
199/// rather than a policy.
200///
201/// `ipam` is the profile's already-built inventory — `None` when `ipam.backend`
202/// is unset, which is a startup error if any selected rule names an `ipam`
203/// check. It is built by [`Profile::build_all`](crate::Profile::build_all)
204/// rather than here because it is its own configuration section with its own
205/// selector, and this policy is one of its consumers rather than its owner.
206pub fn from_config(
207 cfg: &FilterConfig,
208 dns: &crate::config::DnsConfig,
209 ipam: Option<Arc<crate::ipam::IpamRegistry>>,
210 eab_enabled: bool,
211) -> anyhow::Result<Arc<FilterPolicy>> {
212 build::build(cfg, dns, ipam, eab_enabled).map(Arc::new)
213}
214
215/// Parses one allow-list entry as a network.
216///
217/// Accepts both CIDR notation (`192.168.1.0/24`, `fd00::/8`) and a bare address
218/// (`203.0.113.7`), the latter becoming a host route — writing a `/32` for a
219/// single machine is noise an operator should not have to remember.
220pub(crate) fn parse_net(entry: &str) -> anyhow::Result<IpNet> {
221 if let Ok(net) = entry.parse::<IpNet>() {
222 return Ok(net);
223 }
224 match entry.parse::<IpAddr>() {
225 Ok(addr) => Ok(IpNet::from(addr)),
226 Err(_) => anyhow::bail!("invalid network or address: {entry}"),
227 }
228}
229
230/// Parses a list of network entries, naming the setting in any error.
231pub(crate) fn parse_nets(entries: &[String], setting: &str) -> anyhow::Result<Vec<IpNet>> {
232 entries
233 .iter()
234 .map(|entry| parse_net(entry).map_err(|error| anyhow::anyhow!("{setting}: {error}")))
235 .collect()
236}
237
238/// Normalizes an address for comparison.
239///
240/// The default bind is `[::]:3000`, so an IPv4 client arrives over the
241/// dual-stack socket as `::ffff:192.168.1.5` and would never match a
242/// `192.168.1.0/24` rule. Canonicalizing first makes the operator's v4 rules
243/// mean what they look like they mean.
244pub(crate) fn canonical(ip: IpAddr) -> IpAddr {
245 ip.to_canonical()
246}
247
248/// Whether any network contains `ip`, comparing canonical forms.
249pub(crate) fn nets_contain(nets: &[IpNet], ip: IpAddr) -> bool {
250 let ip = canonical(ip);
251 nets.iter().any(|net| net.contains(&ip))
252}
253
254/// The outcome of an allow/deny pair for one value.
255///
256/// Distinguishing the two refusals lets each filter word its own message while
257/// the decision itself stays in one place.
258#[derive(Debug, PartialEq, Eq)]
259pub(crate) enum ListVerdict {
260 /// Nothing objected: either `allow` was empty, or the value matched it.
261 Permitted,
262 /// The value matched `deny`.
263 Denied,
264 /// `allow` was non-empty and the value was not in it.
265 NotAllowed,
266}
267
268/// Applies an allow/deny pair to one value, given a membership test.
269///
270/// All three filters — the IP allowlist over `IpNet`, and the identifier and
271/// reverse-DNS lists over `Regex` — share one rule, stated once here:
272///
273/// * **`deny` is checked first and wins.** Plain membership, not
274/// longest-prefix-match: a `/32` in `allow` does not beat a `/8` in `deny`.
275/// * **An empty `allow` imposes no constraint**, so a deny-only configuration
276/// is a working blocklist rather than a list that refuses everything.
277///
278/// The rule used to be written out three times, with the invariant recorded
279/// only in three doc comments; the one thing keeping them in step is that they
280/// now call this.
281pub(crate) fn check_lists<T>(allow: &[T], deny: &[T], matches: impl Fn(&T) -> bool) -> ListVerdict {
282 if deny.iter().any(&matches) {
283 return ListVerdict::Denied;
284 }
285 if !allow.is_empty() && !allow.iter().any(&matches) {
286 return ListVerdict::NotAllowed;
287 }
288 ListVerdict::Permitted
289}
290
291/// Compiles allow/deny patterns, anchored and case-insensitive.
292///
293/// **Anchoring is not optional.** The `regex` crate searches rather than
294/// matches, so an allow entry of `example\.com` would also accept
295/// `example.com.evil.net` — precisely the bypass an allowlist exists to
296/// prevent. Every pattern becomes `^(?:…)$`; a caller wanting a suffix match
297/// writes `.*\.example\.com`.
298pub(crate) fn compile_anchored(patterns: &[String], setting: &str) -> anyhow::Result<Vec<Regex>> {
299 patterns
300 .iter()
301 .map(|pattern| {
302 RegexBuilder::new(&format!("^(?:{pattern})$"))
303 .case_insensitive(true)
304 .build()
305 .map_err(|error| anyhow::anyhow!("{setting}: invalid regex {pattern:?}: {error}"))
306 })
307 .collect()
308}
309
310/// The identifier types a new `identifiers` check permits when it says nothing.
311///
312/// A function rather than a `const` because an unset list arrives as `[]` from
313/// the environment and the resolver has to substitute this — see
314/// [`CheckConfig`](crate::config::CheckConfig).
315pub(crate) fn default_identifier_types() -> Vec<String> {
316 vec!["dns".to_string(), "cn".to_string()]
317}
318
319/// Turns one glob into a regex source, escaping everything that is not `*`.
320///
321/// `*` becomes `[^.]+` — one label, the wildcard semantics an operator already
322/// knows from certificates, so `*.example.com` matches `a.example.com` and not
323/// `a.b.example.com`. Every other character is escaped, so a name that happens
324/// to contain regex metacharacters cannot smuggle a pattern in.
325///
326/// A glob *is* a regex once it reaches [`compile_anchored`], which is the whole
327/// design: no second matching engine, and the anchoring guarantee is inherited
328/// rather than re-derived.
329pub(crate) fn glob_to_pattern(glob: &str) -> String {
330 glob.split('*')
331 .map(regex::escape)
332 .collect::<Vec<_>>()
333 .join("[^.]+")
334}
335
336/// Compiles one side of a check's matching policy: globs and regexes, unioned.
337///
338/// `side` is `allow` or `deny`; the regex list is the same key suffixed
339/// `_regex`, and each half names its own key in an error so an operator is told
340/// which list to look at.
341pub(crate) fn compile_matchers(
342 globs: &[String],
343 regexes: &[String],
344 check: &str,
345 side: &str,
346) -> anyhow::Result<Vec<Regex>> {
347 let from_globs: Vec<String> = globs.iter().map(|glob| glob_to_pattern(glob)).collect();
348 let mut compiled = compile_anchored(&from_globs, &format!("filter.check.{check}.{side}"))?;
349 compiled.extend(compile_anchored(
350 regexes,
351 &format!("filter.check.{check}.{side}_regex"),
352 )?);
353 Ok(compiled)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn parse_net_accepts_cidr_and_bare_addresses() {
362 assert!(parse_net("192.168.1.0/24").is_ok());
363 assert!(parse_net("fd00::/8").is_ok());
364
365 // A bare address becomes a host route, so an operator does not have to
366 // remember to write `/32`.
367 let host = parse_net("203.0.113.7").unwrap();
368 assert_eq!(host.prefix_len(), 32);
369 assert!(host.contains(&"203.0.113.7".parse::<IpAddr>().unwrap()));
370 assert!(!host.contains(&"203.0.113.8".parse::<IpAddr>().unwrap()));
371
372 let host6 = parse_net("2001:db8::1").unwrap();
373 assert_eq!(host6.prefix_len(), 128);
374
375 assert!(parse_net("not-a-network").is_err());
376 assert!(parse_net("192.168.1.0/99").is_err());
377 }
378
379 #[test]
380 fn nets_contain_canonicalizes_ipv4_mapped_addresses() {
381 let nets = parse_nets(&["192.168.1.0/24".to_string()], "test").unwrap();
382 assert!(nets_contain(&nets, "192.168.1.5".parse().unwrap()));
383 assert!(nets_contain(&nets, "::ffff:192.168.1.5".parse().unwrap()));
384 assert!(!nets_contain(&nets, "10.0.0.1".parse().unwrap()));
385 }
386
387 #[test]
388 fn parse_nets_names_the_offending_setting() {
389 let error = parse_nets(&["garbage".to_string()], "filter.check.net.allow")
390 .unwrap_err()
391 .to_string();
392 assert!(error.contains("filter.check.net.allow"), "{error}");
393 assert!(error.contains("garbage"), "{error}");
394 }
395
396 #[test]
397 fn canonical_unmaps_ipv4_in_ipv6() {
398 assert_eq!(
399 canonical("::ffff:10.0.0.1".parse().unwrap()),
400 "10.0.0.1".parse::<IpAddr>().unwrap()
401 );
402 }
403
404 #[test]
405 fn compile_anchored_prevents_suffix_bypass() {
406 let patterns =
407 compile_anchored(&[r"example\.com".to_string()], "filter.check.x.allow").unwrap();
408 assert!(patterns[0].is_match("example.com"));
409 // Unanchored, `regex` would have found this.
410 assert!(!patterns[0].is_match("example.com.evil.net"));
411 assert!(!patterns[0].is_match("notexample.com"));
412 }
413
414 #[test]
415 fn compile_anchored_is_case_insensitive() {
416 let patterns = compile_anchored(&[r"host\.example\.com".to_string()], "test").unwrap();
417 assert!(patterns[0].is_match("HOST.Example.COM"));
418 }
419
420 #[test]
421 fn compile_anchored_reports_a_bad_pattern() {
422 let error = compile_anchored(&["[unclosed".to_string()], "filter.check.x.deny")
423 .unwrap_err()
424 .to_string();
425 assert!(error.contains("filter.check.x.deny"), "{error}");
426 assert!(error.contains("[unclosed"), "{error}");
427 }
428
429 /// The glob vocabulary, table-driven. `*` is one label and nothing else is
430 /// a metacharacter — those two rules are the whole surface, and both have
431 /// bypasses behind them if they slip.
432 #[test]
433 fn a_glob_star_is_one_label_and_everything_else_is_literal() {
434 let cases: &[(&str, &str, bool)] = &[
435 ("*.example.com", "a.example.com", true),
436 ("*.example.com", "A.Example.COM", true),
437 ("*.example.com", "a.b.example.com", false),
438 ("*.example.com", "example.com", false),
439 ("*.example.com", "aexample.com", false),
440 ("example.com", "example.com", true),
441 // `.` must not behave as a regex wildcard.
442 ("example.com", "exampleXcom", false),
443 // Neither must anything else a hostname could carry.
444 ("a+b.example.com", "a+b.example.com", true),
445 ("a+b.example.com", "aab.example.com", false),
446 // A literal `*` in the requested value is matched by `*`, which is
447 // what lets `allow_wildcards` policies name the wildcard form.
448 ("*.example.com", "*.example.com", true),
449 ("host-*.example.com", "host-1.example.com", true),
450 ("host-*.example.com", "host-1.2.example.com", false),
451 ];
452
453 for (glob, value, expected) in cases {
454 let compiled =
455 compile_anchored(&[glob_to_pattern(glob)], "test").expect("a glob always compiles");
456 assert_eq!(
457 compiled[0].is_match(value),
458 *expected,
459 "glob {glob:?} against {value:?}"
460 );
461 }
462 }
463
464 #[test]
465 fn compile_matchers_unions_globs_and_regexes_and_names_each_key() {
466 let compiled = compile_matchers(
467 &["*.example.com".to_string()],
468 &[r"host\d+\.internal".to_string()],
469 "names",
470 "allow",
471 )
472 .unwrap();
473 assert_eq!(compiled.len(), 2);
474 assert!(compiled[0].is_match("a.example.com"));
475 assert!(compiled[1].is_match("host12.internal"));
476
477 let error = compile_matchers(&[], &["[unclosed".to_string()], "names", "deny")
478 .unwrap_err()
479 .to_string();
480 assert!(error.contains("filter.check.names.deny_regex"), "{error}");
481 }
482
483 #[test]
484 fn identifier_stage_labels() {
485 assert_eq!(IdentifierStage::NewOrder.as_str(), "newOrder");
486 assert_eq!(IdentifierStage::Csr.as_str(), "CSR");
487 }
488
489 #[test]
490 fn check_lists_implements_the_shared_allow_deny_rule() {
491 fn matches(value: &'static str) -> impl Fn(&&str) -> bool {
492 move |entry: &&str| *entry == value
493 }
494
495 // Empty allow imposes no constraint, which is what makes a deny-only
496 // configuration a working blocklist.
497 assert_eq!(
498 check_lists::<&str>(&[], &[], matches("a")),
499 ListVerdict::Permitted
500 );
501 assert_eq!(check_lists(&[], &["a"], matches("a")), ListVerdict::Denied);
502 assert_eq!(
503 check_lists(&["a"], &[], matches("a")),
504 ListVerdict::Permitted
505 );
506 assert_eq!(
507 check_lists(&["b"], &[], matches("a")),
508 ListVerdict::NotAllowed
509 );
510 // Deny is checked first and wins even over an explicit allow.
511 assert_eq!(
512 check_lists(&["a"], &["a"], matches("a")),
513 ListVerdict::Denied
514 );
515 }
516
517 #[test]
518 fn require_client_ip_fails_closed_and_canonicalizes() {
519 assert_eq!(
520 require_client_ip(Some("::ffff:10.0.0.1".parse().unwrap())).unwrap(),
521 "10.0.0.1".parse::<IpAddr>().unwrap()
522 );
523
524 // A refusal, not an unknown: see the doc comment.
525 match require_client_ip(None) {
526 Err(Verdict::Fail(detail)) => assert!(detail.contains("unavailable"), "{detail}"),
527 other => panic!("expected Fail, got {other:?}"),
528 }
529 }
530
531 #[test]
532 fn the_default_identifier_types_are_dns_and_cn() {
533 assert_eq!(default_identifier_types(), vec!["dns", "cn"]);
534 }
535}