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}