Skip to main content

dynamic_config_server/config/
validate.rs

1//! Which refusal applies, and the order the checks run in.
2//!
3//! Pure, and separate from starting, so the whole refusal surface is
4//! testable without a socket: [`Server::start`](crate::Server::start) calls
5//! [`validate`](ServerConfig::validate) first and does nothing else if it
6//! says no.
7
8use std::net::SocketAddr;
9
10use super::{Refusal, ServerConfig};
11use crate::auth::MIN_TOKEN_LEN;
12
13impl ServerConfig {
14    /// Every reason this configuration will not start a server.
15    ///
16    /// Pure, and separate from starting, so the whole refusal surface is
17    /// testable without a socket. [`Server::start`](crate::Server::start)
18    /// calls it first and does nothing else if it says no.
19    ///
20    /// # Errors
21    ///
22    /// The first [`Refusal`] that applies, in the order this checks them:
23    /// the shape of the roster before the shape of the network, because an
24    /// operator fixing two problems would rather be told about the one that
25    /// is about who can read what.
26    pub fn validate(&self) -> Result<(), Refusal> {
27        if self.sections.is_empty() {
28            return Err(Refusal::NoSections);
29        }
30        if self.clients.is_empty() {
31            return Err(Refusal::NoClients);
32        }
33
34        let mut seen = Vec::new();
35
36        for section in &self.sections {
37            // The predicate the handlers apply to the path segments, applied
38            // where the mistake was made. Without this a section named with
39            // a space in it — or one 65 characters long — loads, starts, and
40            // reports ready, while every request for it is refused by
41            // `is_name` before the section map is even consulted: a section
42            // that exists and can never be reached.
43            for (part, value) in [
44                ("application", &section.application),
45                ("profile", &section.profile),
46            ] {
47                if !crate::routes::is_name(value) {
48                    return Err(Refusal::UnroutableSection {
49                        application: section.application.clone(),
50                        profile: section.profile.clone(),
51                        part,
52                    });
53                }
54            }
55
56            let pair = (section.application.as_str(), section.profile.as_str());
57
58            if seen.contains(&pair) {
59                return Err(Refusal::DuplicateSection {
60                    application: section.application.clone(),
61                    profile: section.profile.clone(),
62                });
63            }
64
65            seen.push(pair);
66        }
67
68        let mut names: Vec<&str> = Vec::new();
69        let mut anonymous = 0;
70
71        for client in &self.clients {
72            if names.contains(&client.name.as_str()) {
73                return Err(Refusal::DuplicateClient {
74                    name: client.name.clone(),
75                });
76            }
77
78            names.push(&client.name);
79
80            match &client.token {
81                None => {
82                    anonymous += 1;
83
84                    if !self.allow_anonymous {
85                        return Err(Refusal::AnonymousNotAllowed {
86                            client: client.name.clone(),
87                        });
88                    }
89                    if anonymous > 1 {
90                        return Err(Refusal::SeveralAnonymousClients);
91                    }
92                }
93                Some(token) if token.len() < MIN_TOKEN_LEN => {
94                    return Err(Refusal::WeakToken {
95                        client: client.name.clone(),
96                    });
97                }
98                Some(_) => {}
99            }
100
101            // A grant naming an application nothing serves is a typo that
102            // reads as a working deployment right up to the first 404. Two
103            // sections can share an application (one per profile), so the
104            // grant is checked against the application names, not the pairs.
105            for application in &client.applications {
106                if !self
107                    .sections
108                    .iter()
109                    .any(|section| &section.application == application)
110                {
111                    return Err(Refusal::UnservedGrant {
112                        client: client.name.clone(),
113                        application: application.clone(),
114                    });
115                }
116            }
117        }
118
119        // Two clients sharing a token is not a smaller version of one client
120        // with two grants: whichever is listed first silently wins, and the
121        // audit log then names the wrong caller for every request.
122        for (index, client) in self.clients.iter().enumerate() {
123            let Some(token) = &client.token else { continue };
124
125            for other in self.clients.iter().skip(index + 1) {
126                if other.token.as_ref().is_some_and(|it| token.same_as(it)) {
127                    return Err(Refusal::DuplicateToken);
128                }
129            }
130        }
131
132        let address = self
133            .bind
134            .parse::<SocketAddr>()
135            .map_err(|_| Refusal::UnparsableBind {
136                bind: self.bind.clone(),
137            })?;
138
139        // The whole matrix of TLS against the bind, in one place. Four
140        // starting shapes and three refusals:
141        //
142        // | tls     | bind         | insecure | outcome                    |
143        // |---------|--------------|----------|----------------------------|
144        // | absent  | loopback     | either   | starts, in the clear       |
145        // | absent  | non-loopback | false    | `ExposedBind`              |
146        // | absent  | non-loopback | true     | starts; a terminator is in front |
147        // | present | anything     | false    | starts, terminating TLS    |
148        // | present | anything     | true     | `InsecureWithTls`          |
149        // | present | anything     | —        | `TlsUnsupported` if the feature is off |
150        match &self.tls {
151            Some(tls) => {
152                // A build without the feature has no rustls in it at all. The
153                // block is still *parsed* — a key that only exists in some
154                // builds would be an unknown field in the others, and this
155                // crate refuses unknown fields — so the refusal is here,
156                // where it can name the feature.
157                if !cfg!(feature = "tls") {
158                    return Err(Refusal::TlsUnsupported);
159                }
160                // Before the path checks, because this one is about who can
161                // read what and they are about which file: an operator with
162                // both problems would rather hear that revocation is not
163                // checked than that a path is blank.
164                if tls.crl.is_some() {
165                    return Err(Refusal::RevocationUnsupported);
166                }
167                if tls.certificate.trim().is_empty() {
168                    return Err(Refusal::TlsPathMissing { key: "certificate" });
169                }
170                if tls.key.trim().is_empty() {
171                    return Err(Refusal::TlsPathMissing { key: "key" });
172                }
173                if tls
174                    .client_ca
175                    .as_ref()
176                    .is_some_and(|it| it.trim().is_empty())
177                {
178                    return Err(Refusal::TlsPathMissing { key: "client_ca" });
179                }
180                if self.insecure {
181                    return Err(Refusal::InsecureWithTls);
182                }
183            }
184            None => {
185                if !address.ip().is_loopback() && !self.insecure {
186                    return Err(Refusal::ExposedBind {
187                        bind: self.bind.clone(),
188                    });
189                }
190            }
191        }
192
193        Ok(())
194    }
195
196    /// The validated bind address.
197    ///
198    /// # Errors
199    ///
200    /// If `bind` is not a literal `address:port`. A hostname is refused
201    /// rather than resolved: which of a name's addresses a server ends up
202    /// on is not a thing to discover at startup.
203    pub fn address(&self) -> Result<SocketAddr, Refusal> {
204        self.bind
205            .parse::<SocketAddr>()
206            .map_err(|_| Refusal::UnparsableBind {
207                bind: self.bind.clone(),
208            })
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::auth::Token;
216    use crate::config::{ClientConfig, SectionConfig, TlsConfig};
217
218    fn section(application: &str, profile: &str) -> SectionConfig {
219        SectionConfig {
220            application: application.to_owned(),
221            profile: profile.to_owned(),
222            files: vec!["config.toml".to_owned()],
223            env_prefix: None,
224            whole_document: false,
225        }
226    }
227
228    fn client(name: &str, token: Option<&str>, applications: &[&str]) -> ClientConfig {
229        ClientConfig {
230            name: name.to_owned(),
231            token: token.map(Token::new),
232            applications: applications.iter().map(|it| (*it).to_owned()).collect(),
233        }
234    }
235
236    const GOOD: &str = "0123456789abcdef0123456789abcdef";
237    const OTHER: &str = "fedcba9876543210fedcba9876543210";
238
239    fn valid() -> ServerConfig {
240        ServerConfig {
241            sections: vec![section("billing", "prod")],
242            clients: vec![client("billing-pod", Some(GOOD), &["billing"])],
243            ..ServerConfig::default()
244        }
245    }
246
247    #[test]
248    fn a_complete_configuration_starts() {
249        assert_eq!(valid().validate(), Ok(()));
250    }
251
252    #[test]
253    fn an_empty_roster_is_refused_at_both_ends() {
254        let mut config = valid();
255        config.sections.clear();
256        assert_eq!(config.validate(), Err(Refusal::NoSections));
257
258        let mut config = valid();
259        config.clients.clear();
260        assert_eq!(config.validate(), Err(Refusal::NoClients));
261    }
262
263    #[test]
264    fn a_duplicate_section_is_refused_but_two_profiles_are_not() {
265        let mut config = valid();
266        config.sections.push(section("billing", "prod"));
267
268        assert_eq!(
269            config.validate(),
270            Err(Refusal::DuplicateSection {
271                application: "billing".to_owned(),
272                profile: "prod".to_owned(),
273            })
274        );
275
276        let mut config = valid();
277        config.sections.push(section("billing", "staging"));
278        assert_eq!(config.validate(), Ok(()));
279    }
280
281    /// A section whose name no path segment can carry is refused where it
282    /// was written, not answered `404` forever. The predicate is the
283    /// handlers' own, so the two cannot drift.
284    #[test]
285    fn a_section_no_route_could_name_is_refused_at_startup() {
286        for (part, application, profile) in [
287            ("application", "billing api", "prod"),
288            ("profile", "billing", ".hidden"),
289            ("application", "", "prod"),
290            ("profile", "billing", "../etc"),
291        ] {
292            let mut config = valid();
293            config.sections = vec![section(application, profile)];
294            config.clients = vec![client("pod", Some(GOOD), &[application])];
295
296            assert_eq!(
297                config.validate(),
298                Err(Refusal::UnroutableSection {
299                    application: application.to_owned(),
300                    profile: profile.to_owned(),
301                    part,
302                }),
303                "`{application}`/`{profile}` must be refused"
304            );
305        }
306
307        // And the shapes a deployment actually uses still pass.
308        let mut config = valid();
309        config.sections = vec![section("billing-api.v2", "prod_1")];
310        config.clients = vec![client("pod", Some(GOOD), &["billing-api.v2"])];
311        assert_eq!(config.validate(), Ok(()));
312
313        // Sixty-four characters is the ceiling, and it is inclusive.
314        let mut config = valid();
315        let long = "a".repeat(65);
316        config.sections = vec![section(&long, "prod")];
317        config.clients = vec![client("pod", Some(GOOD), &[&long])];
318        assert!(matches!(
319            config.validate(),
320            Err(Refusal::UnroutableSection { .. })
321        ));
322    }
323
324    #[test]
325    fn duplicate_client_names_and_tokens_are_refused() {
326        let mut config = valid();
327        config
328            .clients
329            .push(client("billing-pod", Some(OTHER), &["billing"]));
330
331        assert_eq!(
332            config.validate(),
333            Err(Refusal::DuplicateClient {
334                name: "billing-pod".to_owned()
335            })
336        );
337
338        let mut config = valid();
339        config
340            .clients
341            .push(client("other", Some(GOOD), &["billing"]));
342
343        assert_eq!(config.validate(), Err(Refusal::DuplicateToken));
344    }
345
346    #[test]
347    fn a_short_token_is_refused() {
348        let mut config = valid();
349        config.clients = vec![client("billing-pod", Some("short"), &["billing"])];
350
351        assert_eq!(
352            config.validate(),
353            Err(Refusal::WeakToken {
354                client: "billing-pod".to_owned()
355            })
356        );
357    }
358
359    /// The switch the threat model turns on: no credential is nobody unless
360    /// the deployment says otherwise, in as many words.
361    #[test]
362    fn anonymous_access_needs_an_explicit_opt_in() {
363        let mut config = valid();
364        config.clients = vec![client("anonymous", None, &["billing"])];
365
366        assert_eq!(
367            config.validate(),
368            Err(Refusal::AnonymousNotAllowed {
369                client: "anonymous".to_owned()
370            })
371        );
372
373        config.allow_anonymous = true;
374        assert_eq!(config.validate(), Ok(()));
375
376        config.clients.push(client("also", None, &["billing"]));
377        assert_eq!(config.validate(), Err(Refusal::SeveralAnonymousClients));
378    }
379
380    #[test]
381    fn a_grant_nothing_serves_is_refused() {
382        let mut config = valid();
383        config.clients = vec![client("billing-pod", Some(GOOD), &["biling"])];
384
385        assert_eq!(
386            config.validate(),
387            Err(Refusal::UnservedGrant {
388                client: "billing-pod".to_owned(),
389                application: "biling".to_owned(),
390            })
391        );
392    }
393
394    #[test]
395    fn a_non_loopback_bind_is_refused_without_the_flag() {
396        let mut config = valid();
397        config.bind = "0.0.0.0:8080".to_owned();
398
399        let refusal = config.validate().unwrap_err();
400        assert_eq!(
401            refusal,
402            Refusal::ExposedBind {
403                bind: "0.0.0.0:8080".to_owned()
404            }
405        );
406        assert!(
407            refusal.to_string().contains("insecure"),
408            "the refusal has to name the key that fixes it: {refusal}"
409        );
410
411        config.insecure = true;
412        assert_eq!(config.validate(), Ok(()));
413    }
414
415    fn tls(client_ca: Option<&str>) -> TlsConfig {
416        TlsConfig {
417            certificate: "/etc/tls/server.pem".to_owned(),
418            key: "/etc/tls/server.key".to_owned(),
419            client_ca: client_ca.map(ToOwned::to_owned),
420            crl: None,
421        }
422    }
423
424    /// The half of the matrix that only exists because TLS does: terminating
425    /// it here is itself the answer to "that address is not loopback", so no
426    /// acknowledgement is asked for.
427    #[cfg(feature = "tls")]
428    #[test]
429    fn tls_is_the_acknowledgement_a_non_loopback_bind_needs() {
430        let mut config = valid();
431        config.bind = "0.0.0.0:8443".to_owned();
432        config.tls = Some(tls(None));
433
434        assert_eq!(config.validate(), Ok(()));
435    }
436
437    /// And the refusal that keeps `insecure` meaning one thing. Without it,
438    /// a configuration that had both would keep starting after the TLS block
439    /// was deleted — in the clear, on a public address, having been
440    /// pre-approved months earlier.
441    #[cfg(feature = "tls")]
442    #[test]
443    fn insecure_and_tls_together_are_a_contradiction_rather_than_a_no_op() {
444        let mut config = valid();
445        config.bind = "0.0.0.0:8443".to_owned();
446        config.tls = Some(tls(Some("/etc/tls/ca.pem")));
447        config.insecure = true;
448
449        let refusal = config.validate().unwrap_err();
450
451        assert_eq!(refusal, Refusal::InsecureWithTls);
452        assert!(refusal.to_string().contains("insecure"), "{refusal}");
453    }
454
455    #[cfg(feature = "tls")]
456    #[test]
457    fn a_tls_section_that_names_no_file_is_refused_per_key() {
458        for (key, mut broken) in [
459            ("certificate", tls(None)),
460            ("key", tls(None)),
461            ("client_ca", tls(Some(""))),
462        ] {
463            match key {
464                "certificate" => broken.certificate = String::new(),
465                "key" => broken.key = "   ".to_owned(),
466                _ => {}
467            }
468
469            let mut config = valid();
470            config.tls = Some(broken);
471
472            assert_eq!(config.validate(), Err(Refusal::TlsPathMissing { key }));
473        }
474    }
475
476    /// Revocation is refused rather than half-implemented, and the refusal
477    /// has to name what to do instead — an operator who configured a CRL is
478    /// an operator who has a certificate to withdraw, and leaving them with
479    /// "no" and no answer is how the key ends up back in the file next week.
480    #[cfg(feature = "tls")]
481    #[test]
482    fn a_crl_is_refused_and_the_refusal_names_the_credential_that_can_be_revoked() {
483        let mut config = valid();
484        let mut with_crl = tls(Some("/etc/tls/ca.pem"));
485        with_crl.crl = Some("/etc/tls/clients.crl".to_owned());
486        config.tls = Some(with_crl);
487
488        let refusal = config.validate().unwrap_err();
489
490        assert_eq!(refusal, Refusal::RevocationUnsupported);
491
492        let rendered = refusal.to_string();
493
494        assert!(rendered.contains("`tls.crl`"), "{rendered}");
495        assert!(rendered.contains("token"), "{rendered}");
496        assert!(rendered.contains("short-lived"), "{rendered}");
497    }
498
499    /// The key is *understood* rather than unknown, which is the whole point
500    /// of it existing: `deny_unknown_fields` would otherwise answer an
501    /// operator asking for revocation with "unknown field", which reads as a
502    /// misspelling and sends them looking for the right one.
503    #[test]
504    fn a_crl_key_parses_so_that_the_refusal_can_explain_rather_than_serde() {
505        let config: ServerConfig = serde_json::from_str(
506            r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
507                "clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}],
508                "tls":{"certificate":"c.pem","key":"k.pem","crl":"clients.crl"}}"#,
509        )
510        .expect("the key is understood, not unknown");
511
512        assert_eq!(
513            config.tls.expect("the block parsed").crl.as_deref(),
514            Some("clients.crl")
515        );
516    }
517
518    /// A build with no TLS in it says so, rather than serving in the clear
519    /// on a port the operator believes is encrypted. This is the one refusal
520    /// that is about the binary rather than the file.
521    #[cfg(not(feature = "tls"))]
522    #[test]
523    fn a_build_without_the_feature_refuses_a_tls_section() {
524        let mut config = valid();
525        config.tls = Some(tls(None));
526
527        let refusal = config.validate().unwrap_err();
528
529        assert_eq!(refusal, Refusal::TlsUnsupported);
530        assert!(refusal.to_string().contains("--features tls"), "{refusal}");
531    }
532
533    /// The block parses in *both* builds. It has to: `deny_unknown_fields`
534    /// would otherwise turn a build without the feature into "unknown field
535    /// `tls`", which reads as a typo rather than as a missing feature.
536    #[test]
537    fn a_tls_section_is_understood_whether_or_not_the_feature_is_on() {
538        let config: ServerConfig = serde_json::from_str(
539            r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
540                "clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}],
541                "tls":{"certificate":"c.pem","key":"k.pem","client_ca":"ca.pem"}}"#,
542        )
543        .expect("the shape is complete");
544
545        let tls = config.tls.expect("the block is understood");
546
547        assert_eq!(tls.certificate, "c.pem");
548        assert_eq!(tls.client_ca.as_deref(), Some("ca.pem"));
549    }
550
551    /// Nothing above may have moved the plain-HTTP half of the matrix.
552    #[test]
553    fn without_tls_a_non_loopback_bind_still_needs_the_acknowledgement() {
554        let mut config = valid();
555        config.bind = "0.0.0.0:8080".to_owned();
556
557        assert!(matches!(
558            config.validate(),
559            Err(Refusal::ExposedBind { .. })
560        ));
561
562        config.insecure = true;
563        assert_eq!(config.validate(), Ok(()));
564    }
565
566    #[test]
567    fn ipv6_loopback_counts_as_loopback() {
568        let mut config = valid();
569        config.bind = "[::1]:8080".to_owned();
570
571        assert_eq!(config.validate(), Ok(()));
572    }
573
574    #[test]
575    fn a_hostname_is_refused_rather_than_resolved() {
576        let mut config = valid();
577        config.bind = "localhost:8080".to_owned();
578
579        assert_eq!(
580            config.validate(),
581            Err(Refusal::UnparsableBind {
582                bind: "localhost:8080".to_owned()
583            })
584        );
585    }
586
587    /// Refusals are printed at startup and end up in a log. None of them may
588    /// carry a token there.
589    #[test]
590    fn no_refusal_prints_a_token() {
591        let mut config = valid();
592        config
593            .clients
594            .push(client("other", Some(GOOD), &["billing"]));
595
596        let refusal = config.validate().unwrap_err();
597
598        assert!(
599            !refusal.to_string().contains(GOOD) && !format!("{refusal:?}").contains(GOOD),
600            "a credential escaped through a refusal: {refusal}"
601        );
602    }
603
604    /// The stream ceiling has a default a fleet does not reach, and zero is
605    /// a legal value rather than a refusal: it is how a deployment says it
606    /// does not want long-lived connections at all.
607    #[test]
608    fn the_stream_ceiling_defaults_high_and_zero_is_a_valid_answer() {
609        let config: ServerConfig = serde_json::from_str(
610            r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
611                "clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}]}"#,
612        )
613        .expect("the shape is complete");
614
615        assert_eq!(config.max_stream_connections, 4096);
616        assert_eq!(config.validate(), Ok(()));
617
618        let mut off = config;
619        off.max_stream_connections = 0;
620        assert_eq!(off.validate(), Ok(()));
621    }
622
623    #[test]
624    fn a_key_the_server_does_not_know_is_refused() {
625        let error = serde_json::from_str::<ServerConfig>(
626            r#"{"sections":[],"clients":[],"allow_anonymou":true}"#,
627        )
628        .unwrap_err();
629
630        assert!(error.to_string().contains("unknown field"), "{error}");
631    }
632}