Skip to main content

acme_proxy/filter/
ipam.rs

1//! The `ipam` filter: the client's address must own the names it asks for.
2//!
3//! The other identifier filter, [`identifiers`](super::identifiers), answers
4//! "may *anyone here* have this name certified?" from a static list. This one
5//! answers the narrower question the list cannot express: may **this address**
6//! have **this name** certified? The answer is not configured — it is read from
7//! the [`ipam`](crate::ipam) inventory, which in a managed estate already
8//! records it.
9//!
10//! Everything about *how* the answer is obtained lives in that subsystem: which
11//! product, which queries, which sources are trusted, and the budget the whole
12//! lookup runs under. What is here is the policy built on the answer, and it is
13//! the same policy whichever inventory produced it — which is the point of the
14//! split, and why the 403 an operator reads names their own product rather than
15//! the one this filter was first written for.
16//!
17//! ## Matching is exact
18//!
19//! Case-insensitive and ignoring a trailing dot, but otherwise literal: no
20//! suffix rule, no wildcard expansion. An entry `example.com` does **not**
21//! permit `a.example.com`, and a request for `*.example.com` requires that
22//! exact string in the inventory. This is the same choice
23//! [`compile_anchored`](super::compile_anchored) makes for the regex-based
24//! filters, for the same reason — a rule that quietly covers more than it says
25//! is the bypass an allowlist exists to prevent.
26//!
27//! An `ip` identifier is permitted when it *is* the connecting address (a
28//! machine may always certify the address it is talking from) or when it is
29//! listed like any other name. A `cn` is skipped — see
30//! [`super::SUBJECT_ONLY_TYPES`]. Any other type is refused: an IPAM has
31//! nothing to say about an email address or a URI, and a filter whose job is to
32//! confirm entitlement must refuse what it cannot confirm.
33//!
34//! ## Denied versus Internal
35//!
36//! "The inventory does not associate this name with this address" is a decision
37//! about the client and denies the request. "It answered 500", "the token was
38//! refused", "the lookup timed out" are not — the server failed to reach a
39//! decision, so they become [`Verdict::Undecided`] and surface as a 500 the
40//! client can retry. The split is enforced by the types rather than by care
41//! here: an [`IpamError`](crate::ipam::IpamError) cannot express a refusal, so
42//! every one of them maps to `Internal` and there is no branch in which an
43//! outage could fail open.
44
45use std::net::IpAddr;
46use std::sync::Arc;
47
48use async_trait::async_trait;
49use tracing::debug;
50
51use super::policy::{Check, StageSet, Verdict};
52use super::{IdentifierContext, SUBJECT_ONLY_TYPES, canonical};
53use crate::ipam::{AddressNames, IpamRegistry, normalize};
54
55/// Requires every requested name to be one the inventory associates with the
56/// client's address.
57#[derive(Debug)]
58pub struct IpamFilter {
59    ipam: Arc<IpamRegistry>,
60}
61
62impl IpamFilter {
63    /// Wraps the profile's configured inventory.
64    #[must_use]
65    pub fn new(ipam: Arc<IpamRegistry>) -> Self {
66        Self { ipam }
67    }
68
69    /// The names the inventory holds, or the refusal its answer implies.
70    async fn permitted_names(&self, client_ip: IpAddr) -> Result<AddressNames, Verdict> {
71        let names = self
72            .ipam
73            .names_for(client_ip)
74            .await
75            .map_err(|error| Verdict::Undecided(error.0))?;
76
77        if !names.is_known() {
78            return Err(Verdict::Fail(format!(
79                "{} holds no record of {client_ip}",
80                self.ipam.backend_name()
81            )));
82        }
83
84        Ok(names)
85    }
86}
87
88impl IpamFilter {
89    async fn decide(&self, ctx: &IdentifierContext<'_>) -> Result<(), Verdict> {
90        let client_ip = super::require_client_ip(ctx.client_ip)?;
91        let stage = ctx.stage.as_str();
92        let backend = self.ipam.backend_name();
93
94        // A CSR carrying nothing but a common name asks the inventory no
95        // question, so it is not worth a round trip. Same reasoning as
96        // `FilterPolicy::has_rules_at`.
97        if ctx.identifiers.iter().all(is_subject_only) {
98            return Ok(());
99        }
100
101        let permitted = self.permitted_names(client_ip).await?;
102        let names = permitted.names();
103
104        for identifier in ctx.identifiers {
105            if is_subject_only(identifier) {
106                continue;
107            }
108
109            let typ = identifier.typ.to_ascii_lowercase();
110            let value = normalize(&identifier.value);
111
112            match typ.as_str() {
113                "dns" => {
114                    if !names.contains(&value) {
115                        return Err(Verdict::Fail(format!(
116                            "{stage} identifier {} is not among the names {backend} associates \
117                             with {client_ip}",
118                            identifier.value
119                        )));
120                    }
121                }
122                // A machine may always certify the address it is talking from;
123                // any other address has to be listed like a name.
124                "ip" => {
125                    let is_client = value
126                        .parse::<IpAddr>()
127                        .is_ok_and(|ip| canonical(ip) == client_ip);
128                    if !is_client && !names.contains(&value) {
129                        return Err(Verdict::Fail(format!(
130                            "{stage} identifier {} is neither {client_ip} nor a name {backend} \
131                             associates with it",
132                            identifier.value
133                        )));
134                    }
135                }
136                other => {
137                    return Err(Verdict::Fail(format!(
138                        "{stage} requests a {other} identifier, which {backend} cannot confirm \
139                         for {client_ip}"
140                    )));
141                }
142            }
143        }
144
145        debug!(
146            event = "filter_ipam_accepted",
147            outcome = "success",
148            backend,
149            client_ip = %client_ip,
150            stage,
151            identifiers = ctx.identifiers.len(),
152        );
153        Ok(())
154    }
155}
156
157#[async_trait]
158impl Check for IpamFilter {
159    fn kind(&self) -> &'static str {
160        "ipam"
161    }
162
163    /// Identifiers only, and not overridable: a connection hook would query the
164    /// inventory on every `newNonce`.
165    fn stages(&self) -> StageSet {
166        StageSet::identifiers_only()
167    }
168
169    async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Verdict {
170        self.decide(context).await.err().unwrap_or(Verdict::Pass)
171    }
172}
173
174/// Whether this identifier is subject metadata this filter leaves alone.
175fn is_subject_only(identifier: &crate::sqlite::order::Identifier) -> bool {
176    SUBJECT_ONLY_TYPES.contains(&identifier.typ.to_ascii_lowercase().as_str())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::filter::{ConnectionContext, IdentifierStage};
183    use crate::ipam::{Ipam, IpamError};
184    use crate::sqlite::order::Identifier;
185    use crate::testutil::identifiers as ids;
186    use axum::http::Method;
187    use std::sync::atomic::{AtomicUsize, Ordering};
188    use std::time::Duration;
189
190    /// An inventory answering from a canned set, never touching the network.
191    struct StubIpam {
192        names: Option<Vec<&'static str>>,
193        error: Option<&'static str>,
194        calls: AtomicUsize,
195    }
196
197    impl StubIpam {
198        /// A recorded address owning `names`.
199        fn owning(names: &[&'static str]) -> Self {
200            Self {
201                names: Some(names.to_vec()),
202                error: None,
203                calls: AtomicUsize::new(0),
204            }
205        }
206
207        /// An address the inventory has never heard of.
208        fn unknown() -> Self {
209            Self {
210                names: None,
211                error: None,
212                calls: AtomicUsize::new(0),
213            }
214        }
215
216        fn failing(error: &'static str) -> Self {
217            Self {
218                names: None,
219                error: Some(error),
220                calls: AtomicUsize::new(0),
221            }
222        }
223    }
224
225    #[async_trait]
226    impl Ipam for StubIpam {
227        fn name(&self) -> &'static str {
228            "StubIPAM"
229        }
230
231        async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
232            self.calls.fetch_add(1, Ordering::SeqCst);
233            if let Some(error) = self.error {
234                return Err(IpamError(error.to_string()));
235            }
236            match &self.names {
237                None => Ok(AddressNames::Unknown),
238                Some(names) => {
239                    let mut answer = AddressNames::known();
240                    for name in names {
241                        answer.insert(name);
242                    }
243                    Ok(answer)
244                }
245            }
246        }
247    }
248
249    fn filter_over(stub: Arc<StubIpam>) -> IpamFilter {
250        IpamFilter::new(Arc::new(IpamRegistry::new(stub, Duration::from_secs(5))))
251    }
252
253    fn filter(stub: StubIpam) -> IpamFilter {
254        filter_over(Arc::new(stub))
255    }
256
257    async fn check_from(
258        filter: &IpamFilter,
259        ip: Option<&str>,
260        identifiers: &[Identifier],
261    ) -> Verdict {
262        filter
263            .check_identifiers(&IdentifierContext {
264                client_ip: ip.map(|ip| ip.parse().unwrap()),
265                account_id: "acct-1",
266                stage: IdentifierStage::NewOrder,
267                identifiers,
268
269                eab: None,
270            })
271            .await
272    }
273
274    async fn check(filter: &IpamFilter, identifiers: &[Identifier]) -> Verdict {
275        check_from(filter, Some("10.0.0.5"), identifiers).await
276    }
277
278    fn assert_denied(verdict: Verdict, needle: &str) {
279        match verdict {
280            Verdict::Fail(detail) => {
281                assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
282            }
283            other => panic!("expected Fail, got {other:?}"),
284        }
285    }
286
287    fn assert_internal(verdict: Verdict, needle: &str) {
288        match verdict {
289            Verdict::Undecided(detail) => {
290                assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
291            }
292            other => panic!("expected Undecided, got {other:?}"),
293        }
294    }
295
296    // ------------------------------------------------------- the happy paths
297
298    #[tokio::test]
299    async fn a_listed_name_is_permitted() {
300        let filter = filter(StubIpam::owning(&["host.example.com"]));
301
302        assert_eq!(
303            check(&filter, &ids(&[("dns", "host.example.com")])).await,
304            Verdict::Pass
305        );
306    }
307
308    #[tokio::test]
309    async fn matching_ignores_case_and_a_trailing_dot() {
310        let filter = filter(StubIpam::owning(&["host.example.com"]));
311
312        assert_eq!(
313            check(&filter, &ids(&[("dns", "HOST.example.com.")])).await,
314            Verdict::Pass
315        );
316    }
317
318    #[tokio::test]
319    async fn every_requested_name_must_be_permitted() {
320        let filter = filter(StubIpam::owning(&["a.example.com", "b.example.com"]));
321
322        assert_eq!(
323            check(
324                &filter,
325                &ids(&[("dns", "a.example.com"), ("dns", "b.example.com")]),
326            )
327            .await,
328            Verdict::Pass
329        );
330
331        let error = check(
332            &filter,
333            &ids(&[("dns", "a.example.com"), ("dns", "c.example.com")]),
334        )
335        .await;
336        assert_denied(error, "c.example.com");
337    }
338
339    #[tokio::test]
340    async fn an_ipv4_mapped_client_is_canonicalized_before_the_lookup() {
341        let filter = filter(StubIpam::owning(&["host.example.com"]));
342
343        assert_eq!(
344            check_from(
345                &filter,
346                Some("::ffff:10.0.0.5"),
347                &ids(&[("dns", "host.example.com")]),
348            )
349            .await,
350            Verdict::Pass
351        );
352    }
353
354    // ------------------------------------------------------------- refusals
355
356    /// The refusal names the identifier, the stage and the product, so an
357    /// operator reading a 403 knows which inventory to go and edit.
358    #[tokio::test]
359    async fn an_unlisted_name_is_denied_naming_it_the_stage_and_the_backend() {
360        let filter = filter(StubIpam::owning(&["host.example.com"]));
361
362        let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
363        assert_denied(error, "newOrder identifier evil.example.com");
364
365        let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
366        assert_denied(error, "StubIPAM associates with 10.0.0.5");
367    }
368
369    /// An address the inventory has never heard of is worded differently from
370    /// one recorded and entitled to nothing — the reason `AddressNames` is an
371    /// enum rather than a set that may be empty.
372    #[tokio::test]
373    async fn an_unrecorded_address_is_denied_saying_so() {
374        let filter = filter(StubIpam::unknown());
375
376        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
377        assert_denied(error, "StubIPAM holds no record of 10.0.0.5");
378    }
379
380    #[tokio::test]
381    async fn a_recorded_address_owning_nothing_is_denied_per_name() {
382        let filter = filter(StubIpam::owning(&[]));
383
384        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
385        assert_denied(error, "is not among the names");
386    }
387
388    #[tokio::test]
389    async fn a_missing_client_address_is_denied() {
390        let filter = filter(StubIpam::owning(&["host.example.com"]));
391
392        let error = check_from(&filter, None, &ids(&[("dns", "host.example.com")])).await;
393        assert_denied(error, "client address unavailable");
394    }
395
396    // --------------------------------------------------- Internal, not Denied
397
398    /// The property the whole `IpamError`/`AddressNames` split exists for: an
399    /// outage must stop issuance with a retryable 500, never fail open and
400    /// never look like a permanent refusal.
401    #[tokio::test]
402    async fn a_failed_lookup_is_internal_not_a_denial() {
403        let filter = filter(StubIpam::failing("HTTP 500"));
404
405        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
406        assert_internal(error, "HTTP 500");
407    }
408
409    /// The registry's budget surfaces here as an ordinary `Internal`.
410    #[tokio::test]
411    async fn a_wedged_inventory_times_out_rather_than_hanging() {
412        struct Hanging;
413        #[async_trait]
414        impl Ipam for Hanging {
415            fn name(&self) -> &'static str {
416                "StubIPAM"
417            }
418            async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
419                tokio::time::sleep(Duration::from_secs(3600)).await;
420                unreachable!("the registry's budget expires first")
421            }
422        }
423
424        let filter = IpamFilter::new(Arc::new(IpamRegistry::new(
425            Arc::new(Hanging),
426            Duration::from_millis(10),
427        )));
428
429        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
430        assert_internal(error, "timed out after 10ms");
431    }
432
433    // -------------------------------------------------------------- wildcards
434
435    #[tokio::test]
436    async fn a_wildcard_needs_the_literal_entry() {
437        let filter = filter(StubIpam::owning(&["example.com"]));
438
439        let error = check(&filter, &ids(&[("dns", "*.example.com")])).await;
440        assert_denied(error, "*.example.com");
441    }
442
443    #[tokio::test]
444    async fn a_literal_wildcard_entry_permits_the_wildcard() {
445        let filter = filter(StubIpam::owning(&["*.example.com"]));
446
447        assert_eq!(
448            check(&filter, &ids(&[("dns", "*.example.com")])).await,
449            Verdict::Pass
450        );
451    }
452
453    #[tokio::test]
454    async fn a_wildcard_entry_does_not_expand_to_subdomains() {
455        let filter = filter(StubIpam::owning(&["*.example.com"]));
456
457        let error = check(&filter, &ids(&[("dns", "a.example.com")])).await;
458        assert_denied(error, "a.example.com");
459    }
460
461    // ------------------------------------------------------ identifier types
462
463    #[tokio::test]
464    async fn the_connecting_address_may_always_be_certified() {
465        let filter = filter(StubIpam::owning(&["host.example.com"]));
466
467        assert_eq!(
468            check(&filter, &ids(&[("ip", "10.0.0.5")])).await,
469            Verdict::Pass
470        );
471    }
472
473    #[tokio::test]
474    async fn another_address_is_denied_unless_listed() {
475        let filter = filter(StubIpam::owning(&["host.example.com"]));
476
477        let error = check(&filter, &ids(&[("ip", "10.0.0.9")])).await;
478        assert_denied(error, "10.0.0.9");
479    }
480
481    #[tokio::test]
482    async fn another_address_may_be_listed_like_any_other_name() {
483        let filter = filter(StubIpam::owning(&["10.0.0.9"]));
484
485        assert_eq!(
486            check(&filter, &ids(&[("ip", "10.0.0.9")])).await,
487            Verdict::Pass
488        );
489    }
490
491    #[tokio::test]
492    async fn a_common_name_is_left_alone() {
493        let filter = filter(StubIpam::owning(&["host.example.com"]));
494
495        assert_eq!(
496            check(
497                &filter,
498                &ids(&[
499                    ("dns", "host.example.com"),
500                    ("cn", "rcgen self signed cert"),
501                ]),
502            )
503            .await,
504            Verdict::Pass
505        );
506    }
507
508    /// No round trip at all for a CSR carrying nothing but a common name.
509    #[tokio::test]
510    async fn a_request_of_common_names_alone_asks_the_inventory_nothing() {
511        let stub = Arc::new(StubIpam::failing("must not be called"));
512        let filter = filter_over(stub.clone());
513
514        assert_eq!(
515            check(&filter, &ids(&[("cn", "some label")])).await,
516            Verdict::Pass
517        );
518        assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
519    }
520
521    #[tokio::test]
522    async fn a_type_an_inventory_cannot_speak_to_is_denied() {
523        let filter = filter(StubIpam::owning(&["host.example.com"]));
524
525        for typ in ["email", "uri", "other"] {
526            let error = check(&filter, &ids(&[(typ, "whatever")])).await;
527            assert_denied(error, &format!("requests a {typ} identifier"));
528        }
529    }
530
531    // ------------------------------------------------------ startup + wiring
532
533    #[test]
534    fn reports_its_type_and_stages() {
535        let check = filter(StubIpam::unknown());
536        assert_eq!(check.kind(), "ipam");
537        assert_eq!(check.stages(), StageSet::identifiers_only());
538    }
539
540    #[tokio::test]
541    async fn does_not_inspect_connections() {
542        let stub = Arc::new(StubIpam::failing("must not be called"));
543        let filter = filter_over(stub.clone());
544
545        assert_eq!(
546            filter
547                .check_connection(&ConnectionContext {
548                    client_ip: Some("203.0.113.9".parse().unwrap()),
549                    method: &Method::POST,
550                    path: "/newOrder",
551                })
552                .await,
553            Verdict::Pass
554        );
555        assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
556    }
557
558    #[test]
559    fn the_debug_impl_names_the_backend() {
560        let rendered = format!("{:?}", filter(StubIpam::unknown()));
561        assert!(rendered.contains("StubIPAM"), "{rendered}");
562    }
563}