Skip to main content

acme_proxy/filter/
path.rs

1//! The `path` check: which request paths a rule applies to.
2//!
3//! Matched against the **profile-stripped** path, so `/directory` — not
4//! `/profile/default/directory` — is the value to list, and one check covers
5//! every endpoint.
6//!
7//! ## What it replaces, and the trap it exists for
8//!
9//! This is the successor to `filter.exempt_paths`, and the reason it is a check
10//! rather than a list is that a path is rarely the whole answer. As a rule it
11//! composes: `public-paths or mgmt-net` opens a path to everyone *or* an
12//! address to everything, and `not public-paths and mgmt-net` restricts a
13//! sensitive path to one network. The old list could only say "skip the
14//! connection stage entirely for this exact string".
15//!
16//! The concrete reason an operator needs it: **`/crl` is served by the profile
17//! router**, so it sits behind the policy like every other ACME resource. Turn
18//! on an address-based check without exempting it and every relying party
19//! outside the allowlist silently loses revocation checking — and relying
20//! parties are precisely not the ACME clients that were allowlisted. A
21//! `public-paths` check listing `/crl` is the companion to any address policy.
22//!
23//! Server-level routes (`GET /health`, `/`, the http-01 responder) are served
24//! by the *root* router, which no profile's policy ever sees, so they need no
25//! entry here.
26//!
27//! ## Globs
28//!
29//! `*` matches one or more characters other than `/` — one path segment, the
30//! same "one component" reading `*` has in the name checks. So
31//! `/renewalInfo/*` covers every certificate id and nothing deeper, which the
32//! exact-match list it replaces could not express at all.
33
34use async_trait::async_trait;
35use regex::Regex;
36use tracing::info;
37
38use super::policy::{Check, StageSet, Verdict};
39use super::{ConnectionContext, ListVerdict, check_lists, compile_anchored};
40
41/// Resolved `[filter.check.<name>]` settings for `type = "path"`.
42#[derive(Debug, Clone, Default)]
43pub struct Settings {
44    pub allow: Vec<String>,
45    pub deny: Vec<String>,
46}
47
48/// Accepts or refuses a request by the path it asks for.
49#[derive(Debug)]
50pub struct PathList {
51    allow: Vec<Regex>,
52    deny: Vec<Regex>,
53}
54
55impl PathList {
56    /// Compiles the path globs, failing startup on an empty policy.
57    pub fn from_settings(name: &str, settings: &Settings) -> anyhow::Result<Self> {
58        if settings.allow.is_empty() && settings.deny.is_empty() {
59            anyhow::bail!(
60                "filter.check.{name} has neither allow nor deny entries, so it would match \
61                 every request; list the paths it is about, or drop the check"
62            );
63        }
64
65        let check = Self {
66            allow: compile_paths(&settings.allow, name, "allow")?,
67            deny: compile_paths(&settings.deny, name, "deny")?,
68        };
69        info!(event = "filter_path_loaded", outcome = "success", check = name, allow = ?settings.allow, deny = ?settings.deny);
70        Ok(check)
71    }
72}
73
74/// A path glob's `*` is one *segment*, so it stops at `/` rather than at `.`.
75///
76/// Deliberately not [`super::glob_to_pattern`]: a path is delimited by `/`
77/// where a DNS name is delimited by `.`, and sharing one substitution would
78/// make `/renewalInfo/*` match `/renewalInfo/a/b`.
79fn compile_paths(globs: &[String], check: &str, side: &str) -> anyhow::Result<Vec<Regex>> {
80    let patterns: Vec<String> = globs
81        .iter()
82        .map(|glob| {
83            glob.split('*')
84                .map(regex::escape)
85                .collect::<Vec<_>>()
86                .join("[^/]+")
87        })
88        .collect();
89    compile_anchored(&patterns, &format!("filter.check.{check}.{side}"))
90}
91
92#[async_trait]
93impl Check for PathList {
94    fn kind(&self) -> &'static str {
95        "path"
96    }
97
98    /// Connection only: by the identifier stage the path is always one of
99    /// `/newOrder` or `/finalize/{id}`, so a rule combining this with a name
100    /// check would be asking a question with a constant answer.
101    fn stages(&self) -> StageSet {
102        StageSet::connection_only()
103    }
104
105    async fn check_connection(&self, context: &ConnectionContext<'_>) -> Verdict {
106        let path = context.path;
107        match check_lists(&self.allow, &self.deny, |pattern| pattern.is_match(path)) {
108            ListVerdict::Permitted => Verdict::Pass,
109            ListVerdict::Denied => Verdict::Fail(format!("path {path} is denied")),
110            ListVerdict::NotAllowed => Verdict::Fail(format!("path {path} is not allowed")),
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use axum::http::Method;
119
120    fn settings(allow: &[&str], deny: &[&str]) -> Settings {
121        Settings {
122            allow: allow.iter().map(std::string::ToString::to_string).collect(),
123            deny: deny.iter().map(std::string::ToString::to_string).collect(),
124        }
125    }
126
127    fn built(allow: &[&str], deny: &[&str]) -> PathList {
128        PathList::from_settings("paths", &settings(allow, deny)).unwrap()
129    }
130
131    async fn verdict_for(check: &PathList, path: &str) -> Verdict {
132        check
133            .check_connection(&ConnectionContext {
134                client_ip: Some("10.0.0.5".parse().unwrap()),
135                method: &Method::GET,
136                path,
137            })
138            .await
139    }
140
141    #[tokio::test]
142    async fn an_exact_path_matches_only_itself() {
143        let check = built(&["/crl"], &[]);
144        assert_eq!(verdict_for(&check, "/crl").await, Verdict::Pass);
145        assert!(matches!(
146            verdict_for(&check, "/crl/extra").await,
147            Verdict::Fail(_)
148        ));
149        assert!(matches!(
150            verdict_for(&check, "/directory").await,
151            Verdict::Fail(_)
152        ));
153    }
154
155    /// The capability the exact-match list it replaces did not have. `*` stops
156    /// at `/`, so it is one segment rather than a wildcard over the rest.
157    #[tokio::test]
158    async fn a_glob_star_matches_exactly_one_path_segment() {
159        let check = built(&["/renewalInfo/*"], &[]);
160        assert_eq!(
161            verdict_for(&check, "/renewalInfo/abc123").await,
162            Verdict::Pass
163        );
164        assert!(matches!(
165            verdict_for(&check, "/renewalInfo/abc/def").await,
166            Verdict::Fail(_)
167        ));
168        assert!(matches!(
169            verdict_for(&check, "/renewalInfo/").await,
170            Verdict::Fail(_)
171        ));
172    }
173
174    #[tokio::test]
175    async fn deny_wins_and_names_which_list_bit() {
176        let check = built(&["/*"], &["/revokeCert"]);
177        assert_eq!(verdict_for(&check, "/directory").await, Verdict::Pass);
178        match verdict_for(&check, "/revokeCert").await {
179            Verdict::Fail(detail) => assert!(detail.contains("is denied"), "{detail}"),
180            other => panic!("expected Fail, got {other:?}"),
181        }
182
183        let allow_only = built(&["/crl"], &[]);
184        match verdict_for(&allow_only, "/newOrder").await {
185            Verdict::Fail(detail) => assert!(detail.contains("is not allowed"), "{detail}"),
186            other => panic!("expected Fail, got {other:?}"),
187        }
188    }
189
190    #[tokio::test]
191    async fn a_deny_only_check_permits_everything_else() {
192        let check = built(&[], &["/revokeCert"]);
193        assert_eq!(verdict_for(&check, "/directory").await, Verdict::Pass);
194        assert!(matches!(
195            verdict_for(&check, "/revokeCert").await,
196            Verdict::Fail(_)
197        ));
198    }
199
200    /// Nothing in a glob but `*` is a metacharacter, so a path holding regex
201    /// syntax cannot smuggle a pattern in.
202    #[tokio::test]
203    async fn every_other_character_is_literal() {
204        let check = built(&["/a.b"], &[]);
205        assert_eq!(verdict_for(&check, "/a.b").await, Verdict::Pass);
206        assert!(matches!(
207            verdict_for(&check, "/axb").await,
208            Verdict::Fail(_)
209        ));
210    }
211
212    #[test]
213    fn an_empty_check_is_a_startup_error() {
214        let error = PathList::from_settings("paths", &Settings::default())
215            .unwrap_err()
216            .to_string();
217        assert!(error.contains("would match every request"), "{error}");
218        assert!(error.contains("filter.check.paths"), "{error}");
219    }
220
221    #[test]
222    fn reports_its_type_and_stages() {
223        let check = built(&["/crl"], &[]);
224        assert_eq!(check.kind(), "path");
225        assert_eq!(check.stages(), StageSet::connection_only());
226    }
227}