Skip to main content

parse_rust_server/
ip_allowlist.rs

1//! The address allowlist behind `masterKeyIps` and `maintenanceKeyIps`.
2//!
3//! Upstream builds a `net.BlockList` from the configured entries and asks it about the request's
4//! peer address (`middlewares.js:50-64`). The default for both options is the two literal
5//! addresses `127.0.0.1` and `::1` (`Options/Definitions.js:385-388`, `:396-399`), which is why an
6//! unimplemented option is an open control rather than a missing feature: a stock parse-server
7//! honours the master key only from the machine it runs on.
8//!
9//! # Two mechanisms, and measuring only one of them gets both wrong
10//!
11//! Upstream's check is `checkIp`, not `BlockList.check`, and the wrapper is not a thin one. It
12//! implements **five allow-all literals that never reach the block list at all**: `getBlockList`
13//! compares each configured entry against `'::/0'`, `'::'`, `'::0'`, `'0.0.0.0/0'` and `'0.0.0.0'`
14//! by string, sets `allowAllIpv6` or `allowAllIpv4`, and `return`s without adding anything
15//! (`middlewares.js:27-48`). `checkIp` then consults those flags **against the peer's own address
16//! family**, where `isIPv4` is false for an IPv4-mapped IPv6 address (`middlewares.js:50-64`).
17//!
18//! Everything else does go to the block list, which works in one 128-bit space where an IPv4
19//! address is its IPv4-mapped form. So there are two rules, not one:
20//!
21//! - **The five literals are family-scoped.** `::/0` admits every IPv6 peer, mapped ones included,
22//!   and **no** IPv4 peer. `0.0.0.0/0` admits every IPv4 peer and **no** IPv6 peer, mapped ones
23//!   included. Bare `::`, `::0` and `0.0.0.0` mean the same as their `/0` spellings rather than
24//!   naming one address.
25//! - **Every other entry is matched in IPv6 space**, so an IPv4 rule `a.b.c.d/n` becomes
26//!   `::ffff:a.b.c.d/(96+n)`, `127.0.0.1` matches a peer of `::ffff:127.0.0.1`, and `::/64` matches
27//!   an IPv4 peer because the mapped form's top 64 bits are zero.
28//!
29//! Measured through `checkIp` on the Node that builds the pinned parse-server, and the table is
30//! asserted below rather than described:
31//!
32//! | rule | `127.0.0.1` | `::1` | `::ffff:127.0.0.1` |
33//! |---|---|---|---|
34//! | `::/0`, `::`, `::0` | no | yes | yes |
35//! | `0.0.0.0/0`, `0.0.0.0` | yes | no | no |
36//! | `127.0.0.1` | yes | no | yes |
37//! | `::1` | no | yes | no |
38//! | `::/64` | yes | yes | yes |
39//!
40//! **An earlier version of this module drove `BlockList` directly and produced the wrong answer in
41//! both directions**, admitting an IPv4 peer under `::/0` and a mapped peer under `0.0.0.0/0`,
42//! because neither special case exists at that layer. The test below therefore measures the same
43//! layer the server uses. Nothing here is derived from the option's help text, which says the two
44//! families "are not compared against each other" and is true only of the block-list half.
45
46use std::net::IpAddr;
47
48/// One entry: a single address, or a CIDR range, held in IPv6 space.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50struct Rule {
51    /// Masked to `prefix` bits at parse time, as `BlockList.addSubnet` does: a base that is not
52    /// the network address of its own prefix is accepted and canonicalized rather than refused.
53    network: u128,
54    prefix: u8,
55}
56
57impl Rule {
58    fn contains(&self, probe: u128) -> bool {
59        if self.prefix == 0 {
60            return true;
61        }
62        (probe ^ self.network) >> (128 - self.prefix) == 0
63    }
64}
65
66/// Why an entry could not be read.
67///
68/// **Upstream validates too, and this is mostly the same refusal rather than an extra one.**
69/// `Config.validateIps` runs at boot and rejects any entry whose address portion is not an IP,
70/// naming it (`Config.js:627-636`).
71///
72/// Where it is stricter is the **mask**, which upstream strips before checking and then reads
73/// loosely. Three entries upstream accepts are refused here, all measured through `checkIp` at the
74/// pin: `127.0.0.1/999` boots there and throws out of `BlockList.addSubnet` on the first master-key
75/// request; `127.0.0.1/` has an empty mask, which `!mask` reads as absent, so it becomes a bare
76/// address; and `127.0.0.1/32/ignored` is destructured to its first two parts with the rest
77/// discarded. Note that the differential does not cover any of them: it drives `checkIp` with
78/// well-formed rules and says nothing about parsing, so these were measured by hand.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct InvalidIpEntry(pub String);
81
82impl std::fmt::Display for InvalidIpEntry {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "not an IP address or CIDR range: {}", self.0)
85    }
86}
87
88impl std::error::Error for InvalidIpEntry {}
89
90/// The addresses a privileged key may be presented from.
91///
92/// **There is no "unset" state.** An empty allowlist means the key cannot be used at all, which is
93/// upstream's documented behavior for an empty array, and a `ServerConfig` that never sets the
94/// field gets [`IpAllowlist::default`], which is upstream's default rather than "allow anything".
95/// The type deliberately offers no way to spell "no filter" except by writing the range out.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct IpAllowlist {
98    rules: Vec<Rule>,
99    /// `'0.0.0.0/0'` or `'0.0.0.0'` was configured: every IPv4 peer is admitted, and no IPv6 peer,
100    /// **including an IPv4-mapped one**.
101    allow_all_v4: bool,
102    /// `'::/0'`, `'::'` or `'::0'` was configured: every IPv6 peer is admitted, mapped ones
103    /// included, and no IPv4 peer.
104    allow_all_v6: bool,
105}
106
107/// The five entries `getBlockList` intercepts before the block list sees them
108/// (`middlewares.js:30-38`). Matched as strings, exactly as upstream matches them, so a spelling
109/// that is numerically equivalent but textually different (`0000::/0`, `0.0.0.0/32`) is an
110/// ordinary rule under both servers.
111const ALLOW_ALL_V6: [&str; 3] = ["::/0", "::", "::0"];
112const ALLOW_ALL_V4: [&str; 2] = ["0.0.0.0/0", "0.0.0.0"];
113
114impl Default for IpAllowlist {
115    /// `['127.0.0.1', '::1']`: only the machine the server runs on.
116    fn default() -> Self {
117        Self {
118            rules: vec![
119                rule_of(IpAddr::from([127, 0, 0, 1]), None),
120                rule_of(IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1]), None),
121            ],
122            allow_all_v4: false,
123            allow_all_v6: false,
124        }
125    }
126}
127
128impl IpAllowlist {
129    /// Read a list of entries, each a bare address, an `address/prefix`, or one of the five
130    /// allow-all literals.
131    pub fn parse<I, S>(entries: I) -> Result<Self, InvalidIpEntry>
132    where
133        I: IntoIterator<Item = S>,
134        S: AsRef<str>,
135    {
136        let mut out = Self::deny_all();
137        for entry in entries {
138            let entry = entry.as_ref();
139            if ALLOW_ALL_V6.contains(&entry) {
140                out.allow_all_v6 = true;
141            } else if ALLOW_ALL_V4.contains(&entry) {
142                out.allow_all_v4 = true;
143            } else {
144                out.rules.push(parse_entry(entry)?);
145            }
146        }
147        Ok(out)
148    }
149
150    /// The comma-separated spelling an environment variable carries
151    /// (`PARSE_SERVER_MASTER_KEY_IPS`).
152    ///
153    /// **Neither trimmed nor tolerant of an empty value**, because upstream is neither.
154    /// `arrayParser` is `opt.split(',')` and nothing else (`Options/parsers.js:42-50`), and
155    /// `Config.validateIps` then rejects any entry whose address portion is not an IP
156    /// (`Config.js:627-636`). So `"127.0.0.1, ::1"` yields `" ::1"` and refuses to boot upstream,
157    /// and so does an empty value, and both refuse to boot here with the offending entry named.
158    ///
159    /// Trimming looked like a harmless courtesy and it is a divergence in what a configuration
160    /// means: a deployment whose variable boots one server and not the other is worse off than one
161    /// that is told about the space.
162    pub fn parse_env(value: &str) -> Result<Self, InvalidIpEntry> {
163        Self::parse(value.split(','))
164    }
165
166    /// The empty array: the key cannot be used from anywhere, including the server itself.
167    ///
168    /// Reachable only from Rust, which is upstream's situation too: there is no way to pass an
169    /// empty array through an environment variable, and the option's help text says so.
170    pub fn deny_all() -> Self {
171        Self {
172            rules: Vec::new(),
173            allow_all_v4: false,
174            allow_all_v6: false,
175        }
176    }
177
178    /// Is this peer allowed to present the key?
179    ///
180    /// The family test is `isIPv4`, which is **false for an IPv4-mapped IPv6 address**. That is
181    /// what makes `0.0.0.0/0` refuse a peer of `::ffff:127.0.0.1` upstream, so the mapped form is
182    /// deliberately not unwrapped here even though the block-list half below treats it as the
183    /// same address.
184    pub fn allows(&self, peer: IpAddr) -> bool {
185        let peer_is_v4 = matches!(peer, IpAddr::V4(_));
186        if peer_is_v4 && self.allow_all_v4 {
187            return true;
188        }
189        if !peer_is_v4 && self.allow_all_v6 {
190            return true;
191        }
192        let probe = to_v6(peer);
193        self.rules.iter().any(|r| r.contains(probe))
194    }
195
196    /// True when no address at all is permitted.
197    pub fn is_deny_all(&self) -> bool {
198        self.rules.is_empty() && !self.allow_all_v4 && !self.allow_all_v6
199    }
200}
201
202fn parse_entry(entry: &str) -> Result<Rule, InvalidIpEntry> {
203    let invalid = || InvalidIpEntry(entry.to_string());
204    let (address, mask) = match entry.split_once('/') {
205        Some((a, m)) => (a, Some(m.parse::<u8>().map_err(|_| invalid())?)),
206        None => (entry, None),
207    };
208    let address: IpAddr = address.parse().map_err(|_| invalid())?;
209    let width = if address.is_ipv4() { 32 } else { 128 };
210    if mask.is_some_and(|m| m > width) {
211        return Err(invalid());
212    }
213    Ok(rule_of(address, mask))
214}
215
216/// Lift an address and an optional family-relative mask into the shared IPv6 space.
217fn rule_of(address: IpAddr, mask: Option<u8>) -> Rule {
218    let prefix = match address {
219        // An IPv4 rule occupies the `::ffff:0:0/96` block, so its prefix is offset by 96. That
220        // offset is what keeps a genuine IPv6 peer outside every IPv4 rule, including `0.0.0.0/0`.
221        IpAddr::V4(_) => 96 + mask.unwrap_or(32),
222        IpAddr::V6(_) => mask.unwrap_or(128),
223    };
224    let network = to_v6(address);
225    Rule {
226        network: mask_to(network, prefix),
227        prefix,
228    }
229}
230
231fn mask_to(value: u128, prefix: u8) -> u128 {
232    if prefix == 0 {
233        0
234    } else {
235        value & (u128::MAX << (128 - prefix))
236    }
237}
238
239/// The 128-bit form: an IPv4 address becomes `::ffff:a.b.c.d`, which is the same address written
240/// the other way and is how upstream's block list sees it.
241fn to_v6(address: IpAddr) -> u128 {
242    match address {
243        IpAddr::V4(v4) => u128::from(v4.to_ipv6_mapped()),
244        IpAddr::V6(v6) => u128::from(v6),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn ip(s: &str) -> IpAddr {
253        s.parse().expect("test address")
254    }
255
256    fn list(entries: &[&str]) -> IpAllowlist {
257        IpAllowlist::parse(entries).expect("test entries")
258    }
259
260    /// The shipped default, and the defect this release closes. 0.2.0 honoured the master key from
261    /// every one of the refused addresses below.
262    #[test]
263    fn the_default_is_loopback_only() {
264        let allow = IpAllowlist::default();
265        for peer in ["127.0.0.1", "::1", "::ffff:127.0.0.1"] {
266            assert!(allow.allows(ip(peer)), "{peer} must be allowed");
267        }
268        for peer in [
269            "127.0.0.2",
270            "::ffff:127.0.0.2",
271            "10.0.0.1",
272            "192.168.1.10",
273            "::2",
274            "2001:db8::1",
275        ] {
276            assert!(!allow.allows(ip(peer)), "{peer} must be refused");
277        }
278    }
279
280    /// The oracle table, in the order `tools/ip-allowlist-oracle.js` prints it.
281    ///
282    /// **Measured through `checkIp`, not through `BlockList`.** The first version of this table
283    /// drove the block list directly, which skips the five allow-all literals entirely, and it
284    /// blessed two answers that were broader than upstream: an IPv4 peer admitted by `::/0` and a
285    /// mapped peer admitted by `0.0.0.0/0`. A differential that measures the wrong layer is worth
286    /// less than no differential, because it reads as confirmation.
287    ///
288    /// `cargo test -p parse-rust-server --ignored ip_allowlist` re-derives it from the pinned
289    /// checkout, so this cannot drift silently.
290    const ORACLE: &[(&str, &[(&str, bool)])] = &[
291        (
292            "::/0",
293            &[
294                ("127.0.0.1", false),
295                ("::1", true),
296                ("::ffff:127.0.0.1", true),
297                ("127.0.0.2", false),
298                ("10.1.2.3", false),
299                ("::ffff:10.1.2.3", true),
300                ("2001:db8::1", true),
301            ],
302        ),
303        (
304            "::",
305            &[
306                ("127.0.0.1", false),
307                ("::1", true),
308                ("::ffff:127.0.0.1", true),
309                ("2001:db8::1", true),
310            ],
311        ),
312        (
313            "::0",
314            &[
315                ("127.0.0.1", false),
316                ("::1", true),
317                ("::ffff:127.0.0.1", true),
318                ("2001:db8::1", true),
319            ],
320        ),
321        (
322            "0.0.0.0/0",
323            &[
324                ("127.0.0.1", true),
325                ("::1", false),
326                ("::ffff:127.0.0.1", false),
327                ("127.0.0.2", true),
328                ("10.1.2.3", true),
329                ("::ffff:10.1.2.3", false),
330                ("2001:db8::1", false),
331            ],
332        ),
333        (
334            "0.0.0.0",
335            &[
336                ("127.0.0.1", true),
337                ("::1", false),
338                ("::ffff:127.0.0.1", false),
339                ("10.1.2.3", true),
340            ],
341        ),
342        (
343            "127.0.0.1",
344            &[
345                ("127.0.0.1", true),
346                ("::1", false),
347                ("::ffff:127.0.0.1", true),
348                ("127.0.0.2", false),
349                ("10.1.2.3", false),
350            ],
351        ),
352        (
353            "::1",
354            &[
355                ("127.0.0.1", false),
356                ("::1", true),
357                ("::ffff:127.0.0.1", false),
358            ],
359        ),
360        (
361            "::/64",
362            &[
363                ("127.0.0.1", true),
364                ("::1", true),
365                ("::ffff:127.0.0.1", true),
366                ("10.1.2.3", true),
367                ("2001:db8::1", false),
368            ],
369        ),
370        (
371            "10.0.0.0/8",
372            &[
373                ("127.0.0.1", false),
374                ("::ffff:127.0.0.1", false),
375                ("10.1.2.3", true),
376                ("::ffff:10.1.2.3", true),
377                ("2001:db8::1", false),
378            ],
379        ),
380        (
381            "2000::/3",
382            &[
383                ("127.0.0.1", false),
384                ("::ffff:127.0.0.1", false),
385                ("2001:db8::1", true),
386            ],
387        ),
388        (
389            "::ffff:0.0.0.0/96",
390            &[
391                ("127.0.0.1", true),
392                ("::1", false),
393                ("::ffff:127.0.0.1", true),
394                ("10.1.2.3", true),
395            ],
396        ),
397    ];
398
399    #[test]
400    fn every_oracle_row_matches() {
401        for (rule, peers) in ORACLE {
402            let allow = list(&[rule]);
403            for (peer, expected) in *peers {
404                assert_eq!(allow.allows(ip(peer)), *expected, "[{rule}] against {peer}");
405            }
406        }
407    }
408
409    /// The two rows the block-list-only reading got backwards, called out on their own because
410    /// they are the ones that widen a configured boundary rather than narrow it.
411    #[test]
412    fn the_allow_all_literals_are_scoped_to_one_family() {
413        assert!(
414            !list(&["::/0"]).allows(ip("127.0.0.1")),
415            "::/0 is allowAllIpv6 and an IPv4 peer is not IPv6"
416        );
417        assert!(
418            !list(&["0.0.0.0/0"]).allows(ip("::ffff:127.0.0.1")),
419            "isIPv4 is false for a mapped address, so allowAllIpv4 does not apply to it"
420        );
421        // Both together is upstream's documented way to disable the filter entirely.
422        let both = list(&["0.0.0.0/0", "::0"]);
423        for peer in ["127.0.0.1", "::1", "::ffff:127.0.0.1", "2001:db8::1"] {
424            assert!(both.allows(ip(peer)), "{peer}");
425        }
426    }
427
428    /// The bare spellings mean "all of this family", not "this one address". `::` as an ordinary
429    /// rule would match only the unspecified address, and `0.0.0.0` only itself.
430    #[test]
431    fn the_bare_spellings_are_allow_all_rather_than_one_address() {
432        assert!(list(&["::"]).allows(ip("2001:db8::1")));
433        assert!(list(&["0.0.0.0"]).allows(ip("203.0.113.9")));
434    }
435
436    /// Matched as strings, exactly as upstream matches them, so an equivalent-but-different
437    /// spelling stays an ordinary rule on both sides.
438    #[test]
439    fn a_numerically_equivalent_spelling_is_not_a_special_case() {
440        // `0.0.0.0/32` is an ordinary /32, so it matches only the unspecified address.
441        assert!(!list(&["0.0.0.0/32"]).allows(ip("127.0.0.1")));
442    }
443
444    /// A genuine IPv6 peer is not let in by an IPv4 range and the converse.
445    #[test]
446    fn the_two_families_stay_separate_for_genuine_addresses() {
447        assert!(!list(&["0.0.0.0/0"]).allows(ip("2001:db8::1")));
448        assert!(!list(&["2000::/3"]).allows(ip("32.0.0.1")));
449    }
450
451    #[test]
452    fn cidr_ranges_match_by_prefix() {
453        let allow = list(&["10.0.1.0/24", "2001:db8::/32"]);
454        assert!(allow.allows(ip("10.0.1.0")));
455        assert!(allow.allows(ip("10.0.1.255")));
456        assert!(!allow.allows(ip("10.0.2.0")));
457        assert!(allow.allows(ip("2001:db8:1234::9")));
458        assert!(!allow.allows(ip("2001:db9::1")));
459    }
460
461    /// `BlockList.addSubnet` accepts a base that is not its own network address and canonicalizes
462    /// it. Refusing it instead would reject a configuration upstream runs.
463    #[test]
464    fn a_non_canonical_base_is_masked_rather_than_refused() {
465        let allow = list(&["10.0.1.5/24"]);
466        assert!(allow.allows(ip("10.0.1.7")));
467        assert!(allow.allows(ip("10.0.1.5")));
468        assert!(!allow.allows(ip("10.0.2.7")));
469    }
470
471    /// The empty array is not "unset, therefore allow". It is upstream's documented way to say the
472    /// key cannot be used at all.
473    #[test]
474    fn an_empty_allowlist_denies_everything_including_loopback() {
475        let allow = IpAllowlist::deny_all();
476        assert!(allow.is_deny_all());
477        for peer in ["127.0.0.1", "::1", "10.0.0.1"] {
478            assert!(!allow.allows(ip(peer)));
479        }
480        assert_eq!(
481            IpAllowlist::parse::<[&str; 0], &str>([]).expect("empty"),
482            allow
483        );
484    }
485
486    #[test]
487    fn the_env_spelling_is_comma_separated() {
488        let allow = IpAllowlist::parse_env("127.0.0.1,10.0.1.0/24,::1").expect("parse");
489        assert!(allow.allows(ip("127.0.0.1")));
490        assert!(allow.allows(ip("10.0.1.9")));
491        assert!(allow.allows(ip("::1")));
492        assert!(!allow.allows(ip("10.0.2.9")));
493    }
494
495    /// **Not trimmed, and an empty value is not deny-all**, because upstream is neither.
496    /// `arrayParser` splits and nothing else, and `Config.validateIps` then refuses `" ::1"` and
497    /// `""` by name. A variable that boots one server and not the other is the divergence worth
498    /// avoiding; being told about the space is not.
499    #[test]
500    fn the_env_spelling_matches_upstreams_strictness() {
501        assert_eq!(
502            IpAllowlist::parse_env("127.0.0.1, ::1").expect_err("space"),
503            InvalidIpEntry(" ::1".to_string())
504        );
505        assert_eq!(
506            IpAllowlist::parse_env("").expect_err("empty"),
507            InvalidIpEntry(String::new())
508        );
509        assert_eq!(
510            IpAllowlist::parse_env("  ").expect_err("blank"),
511            InvalidIpEntry("  ".to_string())
512        );
513    }
514
515    /// The one place this is deliberately stricter than upstream: an out-of-range prefix.
516    /// `Config.validateIps` strips the mask before checking (`Config.js:629-631`), so
517    /// `127.0.0.1/999` boots, and `BlockList.addSubnet` then throws on the **first master-key
518    /// request**, which upstream answers as a 500. Refusing at boot names the entry instead.
519    #[test]
520    fn an_out_of_range_prefix_is_refused_at_boot_rather_than_on_the_first_request() {
521        assert_eq!(
522            IpAllowlist::parse(["127.0.0.1/999"]).expect_err("mask"),
523            InvalidIpEntry("127.0.0.1/999".to_string())
524        );
525    }
526
527    #[test]
528    fn a_malformed_entry_is_refused_by_name() {
529        for entry in [
530            "",
531            "localhost",
532            "127.0.0.1/33",
533            "::1/129",
534            "127.0.0.1/x",
535            "1.2.3",
536        ] {
537            let e = IpAllowlist::parse([entry]).expect_err("must refuse");
538            assert_eq!(e, InvalidIpEntry(entry.to_string()));
539        }
540        // A zone index is not supported upstream either, and Rust's parser refuses it for us.
541        assert!(IpAllowlist::parse(["fe80::1%lo0"]).is_err());
542    }
543}