Skip to main content

cloudillo_types/
validation.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Shared low-level format validators.
5//!
6//! These live in `cloudillo-types` so that both the high-level Action DSL
7//! (`cloudillo-action`) and the low-level federation request client
8//! (`cloudillo-core`) can use the exact same definition without creating a
9//! dependency cycle between those crates.
10
11use std::borrow::Cow;
12
13use idna::uts46::{AsciiDenyList, DnsLength, Hyphens, Uts46};
14
15use crate::prelude::*;
16
17/// Registration policy bounds on the **A-label** (ASCII) form. DNS itself is checked
18/// separately by `DnsLength::Verify` (≤253 total, 1–63 per label, no empty label — which
19/// is what forbids a leading/trailing dot and `..`).
20const ID_TAG_MIN_LEN: usize = 5;
21const ID_TAG_MAX_LEN: usize = 62;
22
23/// The UTS #46 profile Cloudillo uses, in one place so the validator, the
24/// canonicaliser and the A-label encoder cannot drift.
25///
26/// - [`AsciiDenyList::STD3`] (LDH: letters, digits, hyphen) rather than
27///   `AsciiDenyList::URL`. `URL` denies `%#/:<>?@[\]^|`, space and controls — enough for
28///   the SSRF guard in `cloudillo_core::request` — but still permits `_`, which an
29///   id_tag must not contain. STD3 constrains only ASCII code points, so non-ASCII
30///   U-labels are unaffected.
31/// - [`Hyphens::Allow`], because `Check`/`CheckFirstLast` reject real-world names.
32fn uts46() -> Uts46 {
33	Uts46::new()
34}
35const DENY: AsciiDenyList = AsciiDenyList::STD3;
36const HYPHENS: Hyphens = Hyphens::Allow;
37
38/// Canonical stored form of an id_tag: the UTS #46 **U-label** — decoded
39/// Unicode, case-folded, NFC-normalised.
40///
41/// This is the form every id_tag column holds and every lookup key must be in.
42/// The A-label (`xn--…`) is produced only at the wire boundary by
43/// [`id_tag_to_ascii`]; it is never stored.
44///
45/// Validity is decided by the ToASCII pass, not by ToUnicode: ToUnicode still produces
46/// output for erroneous input, so it cannot be the gate. ToASCII enforces IDNA2008
47/// validity, DNS lengths and the ASCII deny list.
48///
49/// Borrows when the input is already canonical, so hot read paths do not allocate. The
50/// canonicalisation itself lives in [`canonicalize_dns_host`]; this adds only the id_tag
51/// length policy.
52pub fn canonicalize_id_tag(id_tag: &str) -> ClResult<Cow<'_, str>> {
53	let unicode = canonicalize_dns_host(id_tag)?;
54	// The bounds are policy on the A-label. A canonical U-label that is already
55	// ASCII *is* its own A-label, so the common case needs no second encode.
56	let ascii_len =
57		if unicode.is_ascii() { unicode.len() } else { id_tag_to_ascii(&unicode)?.len() };
58	if !(ID_TAG_MIN_LEN..=ID_TAG_MAX_LEN).contains(&ascii_len) {
59		return Err(Error::ValidationError(format!("invalid id_tag length: {id_tag}")));
60	}
61	Ok(unicode)
62}
63
64/// Canonical form of a DNS host name — the same UTS #46 U-label
65/// [`canonicalize_id_tag`] produces, **without** the id_tag length policy.
66///
67/// `ID_TAG_MIN_LEN`/`ID_TAG_MAX_LEN` are a registration rule, not a DNS rule. The
68/// two inbound boundaries that have to decode a host they did not choose — the TLS
69/// SNI name and the HTTP `Host` header — must accept any name DNS itself accepts, or
70/// a short but perfectly valid id_tag stops resolving. DNS validity is still
71/// enforced by `DnsLength::Verify` and the STD3 ASCII deny list, so `/`, `:`,
72/// whitespace and `_` remain impossible here.
73///
74/// Borrows when the input is already canonical.
75pub fn canonicalize_dns_host(id_tag: &str) -> ClResult<Cow<'_, str>> {
76	let trimmed = id_tag.trim();
77	if is_canonical_ascii_host(trimmed) {
78		return Ok(Cow::Borrowed(trimmed));
79	}
80	canonicalize_dns_host_uncached(trimmed)
81}
82
83/// [`canonicalize_dns_host`] without the ASCII fast path — the full UTS #46 pair of
84/// passes. Split out so a test can assert the fast path is exactly equivalent to it.
85fn canonicalize_dns_host_uncached(id_tag: &str) -> ClResult<Cow<'_, str>> {
86	let trimmed = id_tag.trim();
87	// Gate: ToASCII decides validity, and its A-label is what DNS length limits
88	// actually apply to.
89	uts46()
90		.to_ascii(trimmed.as_bytes(), DENY, HYPHENS, DnsLength::Verify)
91		.map_err(|_| Error::ValidationError(format!("invalid id_tag: {id_tag}")))?;
92	// Stored form. The error arm is unreachable given ToASCII succeeded, but
93	// ToUnicode reports separately, so honour it rather than assume.
94	let (unicode, res) = uts46().to_unicode(trimmed.as_bytes(), DENY, HYPHENS);
95	res.map_err(|_| Error::ValidationError(format!("invalid id_tag: {id_tag}")))?;
96	Ok(unicode)
97}
98
99/// True when `s` is provably already the canonical form, so the two UTS #46 passes
100/// can be skipped. Deliberately conservative — anything it is unsure about falls
101/// through to the full path, so the fast path can only ever be an optimisation,
102/// never a second definition of canonical.
103///
104/// Requires: pure ASCII; only `a-z`, `0-9`, `.` and `-`; every label 1..=63 bytes
105/// (which also forbids a leading/trailing dot and `..`); total length <= 253; and no
106/// label starting with `xn--`, since such a label decodes to a U-label and is
107/// therefore not canonical.
108fn is_canonical_ascii_host(s: &str) -> bool {
109	if s.is_empty() || s.len() > 253 {
110		return false;
111	}
112	s.split('.').all(|label| {
113		!label.is_empty()
114			&& label.len() <= 63
115			&& !label.starts_with("xn--")
116			&& label.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
117	})
118}
119
120/// The canonical U-label form of a DNS host, falling back to the input verbatim when
121/// it cannot be decoded.
122///
123/// The inbound counterpart of [`id_tag_to_ascii_lossy`]. A TLS SNI name and a `Host:`
124/// header both arrive as A-labels, while everything this server stores and compares
125/// is the U-label — so a name has to be decoded exactly once, on entry. Lossy for the
126/// same reason its outbound twin is: an undecodable host should simply fail to match,
127/// not take a TLS handshake or a request down.
128///
129/// This also lowercases as a side effect of UTS #46, which is correct — DNS names and
130/// SNI are case-insensitive.
131pub fn dns_host_to_unicode_lossy(host: &str) -> Cow<'_, str> {
132	canonicalize_dns_host(host).unwrap_or(Cow::Borrowed(host))
133}
134
135/// The A-label (punycode) form, for DNS, URLs and TLS. **Never store this** —
136/// [`canonicalize_id_tag`] defines the stored form.
137pub fn id_tag_to_ascii(id_tag: &str) -> ClResult<Cow<'_, str>> {
138	uts46()
139		.to_ascii(id_tag.trim().as_bytes(), DENY, HYPHENS, DnsLength::Verify)
140		.map_err(|_| Error::ValidationError(format!("invalid id_tag: {id_tag}")))
141}
142
143/// The A-label for use in a hostname, falling back to the input verbatim when it
144/// cannot be encoded.
145///
146/// For display / discovery hosts only — a TLS cache key, an ACME identifier, a
147/// CardDAV URL. Federation requests use [`id_tag_to_ascii`] and treat a failure as
148/// a hard error; here the fallback preserves what the pre-IDN code emitted, and an
149/// unencodable id_tag simply fails to match rather than taking a caller down.
150pub fn id_tag_to_ascii_lossy(id_tag: &str) -> Cow<'_, str> {
151	id_tag_to_ascii(id_tag).unwrap_or(Cow::Borrowed(id_tag))
152}
153
154/// Validate an id_tag: `true` iff it is **already canonical**.
155///
156/// Non-canonical is invalid rather than silently normalised, so a mixed-case,
157/// non-NFC or punycoded value can never enter storage or a federation request
158/// and later fail to match itself.
159///
160/// `/`, `@`, `:`, whitespace, `_` and uppercase are all rejected, which is what
161/// `cloudillo_core::request::Request::host_for` relies on to make
162/// `https://cl-o.{id_tag}/…` interpolation injection-safe.
163pub fn validate_id_tag(id_tag: &str) -> bool {
164	canonicalize_id_tag(id_tag).is_ok_and(|canonical| canonical == id_tag)
165}
166
167#[cfg(test)]
168mod tests {
169	use super::*;
170
171	#[test]
172	fn test_validate_id_tag() {
173		assert!(validate_id_tag("alice"));
174		assert!(validate_id_tag("bob-123"));
175		assert!(validate_id_tag("user-name-123"));
176		assert!(validate_id_tag("home.w9.hu"));
177
178		assert!(!validate_id_tag("Al")); // too short
179		assert!(!validate_id_tag("Alice")); // uppercase
180		assert!(!validate_id_tag("alice_123")); // underscore not allowed
181	}
182
183	#[test]
184	fn test_validate_id_tag_rejects_url_injection() {
185		// Path / authority injection attempts must all be rejected so that
186		// `https://cl-o.{id_tag}/api...` cannot be redirected or smuggled.
187		assert!(!validate_id_tag("alice/../../etc"));
188		assert!(!validate_id_tag("alice/admin"));
189		assert!(!validate_id_tag("alice@evil.com"));
190		assert!(!validate_id_tag("alice:8080"));
191		assert!(!validate_id_tag("alice evil"));
192		assert!(!validate_id_tag("alice?x=1"));
193		assert!(!validate_id_tag("alice#frag"));
194		assert!(!validate_id_tag("alice\\evil"));
195		assert!(!validate_id_tag("")); // empty
196	}
197
198	#[test]
199	fn test_canonicalize_id_tag_case_folds_to_unicode() {
200		// Case folded, but stays Unicode: the U-label is the stored form.
201		assert_eq!(canonicalize_id_tag("MÜNCHEN.example.com").unwrap(), "münchen.example.com");
202		// An A-label on input decodes to the same canonical U-label.
203		assert_eq!(
204			canonicalize_id_tag("xn--mnchen-3ya.example.com").unwrap(),
205			"münchen.example.com"
206		);
207	}
208
209	#[test]
210	fn test_canonicalize_id_tag_normalises_to_nfc() {
211		// `e` + U+0301 (combining acute) must land on the precomposed `é`, or the
212		// same name typed two ways would be two distinct identities.
213		assert_eq!(canonicalize_id_tag("cafe\u{0301}.example.com").unwrap(), "café.example.com");
214	}
215
216	#[test]
217	fn test_canonicalize_id_tag_borrows_when_canonical() {
218		// The no-allocation hot path.
219		assert!(matches!(canonicalize_id_tag("alice.example.com"), Ok(Cow::Borrowed(_))));
220	}
221
222	#[test]
223	fn test_canonicalize_id_tag_is_idempotent() {
224		for input in ["alice.example.com", "MÜNCHEN.example.com", "xn--mnchen-3ya.example.com"] {
225			let once = canonicalize_id_tag(input).unwrap().into_owned();
226			let twice = canonicalize_id_tag(&once).unwrap().into_owned();
227			assert_eq!(once, twice);
228		}
229	}
230
231	#[test]
232	fn test_id_tag_to_ascii() {
233		assert_eq!(id_tag_to_ascii("münchen.example.com").unwrap(), "xn--mnchen-3ya.example.com");
234		// ASCII passes through unchanged.
235		assert_eq!(id_tag_to_ascii("alice.example.com").unwrap(), "alice.example.com");
236		// A-label input is already ASCII and stays put. The full round trip is covered
237		// by the SNI decode test below.
238		assert_eq!(
239			id_tag_to_ascii("xn--mnchen-3ya.example.com").unwrap(),
240			"xn--mnchen-3ya.example.com"
241		);
242	}
243
244	#[test]
245	fn test_validate_id_tag_accepts_u_labels_only() {
246		assert!(validate_id_tag("münchen.example.com"));
247		// A stored id_tag is never the A-label.
248		assert!(!validate_id_tag("xn--mnchen-3ya.example.com"));
249	}
250
251	#[test]
252	fn canonicalize_dns_host_ignores_the_id_tag_length_policy() {
253		assert_eq!(canonicalize_dns_host("dev").expect("valid"), "dev");
254		// …but `canonicalize_id_tag` still enforces it, which is what registration and
255		// the federation client rely on.
256		assert!(canonicalize_id_tag("dev").is_err());
257		// DNS validity is still enforced.
258		for bad in ["a_b", "a b", "a/b", "a..b", ".a.b", "a.b.", ""] {
259			assert!(canonicalize_dns_host(bad).is_err(), "expected reject for {bad:?}");
260		}
261	}
262
263	#[test]
264	fn dns_host_to_unicode_lossy_decodes_and_falls_back() {
265		assert_eq!(dns_host_to_unicode_lossy("xn--mnchen-3ya.example.com"), "münchen.example.com");
266		assert_eq!(dns_host_to_unicode_lossy("ALICE.example.com"), "alice.example.com");
267		// Undecodable input is passed through so the caller simply fails to match.
268		assert_eq!(dns_host_to_unicode_lossy("a_b"), "a_b");
269	}
270
271	/// The TLS path: ACME issues for the A-label, SNI presents the A-label, and
272	/// everything stored is the U-label. The decode on entry has to land exactly on
273	/// the stored form.
274	#[test]
275	fn an_sni_name_decodes_to_the_stored_form() {
276		let stored = canonicalize_id_tag("MÜNCHEN.example.com").expect("valid").into_owned();
277		let on_the_wire = id_tag_to_ascii(&stored).expect("encodable").into_owned();
278		assert_eq!(on_the_wire, "xn--mnchen-3ya.example.com");
279		assert_eq!(dns_host_to_unicode_lossy(&on_the_wire), stored);
280		// …and the `cl-o.` host the resolver strips its prefix from.
281		assert_eq!(
282			dns_host_to_unicode_lossy(&format!("cl-o.{on_the_wire}")),
283			format!("cl-o.{stored}")
284		);
285	}
286
287	/// The fast path is an optimisation, never a second definition. Any input it
288	/// accepts must produce exactly what the full UTS #46 passes produce.
289	#[test]
290	fn the_ascii_fast_path_agrees_with_the_full_canonicalisation() {
291		for input in [
292			"alice.example.com",
293			"a.bc",
294			"dev",
295			"a",
296			"user-name-123",
297			"-leading-hyphen.example.com",
298			"trailing-hyphen-.example.com",
299			"123.456.example.com",
300			"  alice.example.com  ",
301			"Alice.Example.COM",
302			"münchen.example.com",
303			"MÜNCHEN.example.com",
304			"xn--mnchen-3ya.example.com",
305			"cafe\u{0301}.example.com",
306			"alice..example.com",
307			".alice.example.com",
308			"alice.example.com.",
309			"alice_123.example.com",
310			"alice/evil.example.com",
311			"alice evil.example.com",
312			"",
313			"   ",
314		] {
315			let fast = canonicalize_dns_host(input);
316			let slow = canonicalize_dns_host_uncached(input.trim());
317			match (fast, slow) {
318				(Ok(a), Ok(b)) => assert_eq!(a, b, "disagreement on {input:?}"),
319				(Err(_), Err(_)) => {}
320				(a, b) => panic!("fast/slow disagree on {input:?}: {a:?} vs {b:?}"),
321			}
322		}
323	}
324}
325
326// vim: ts=4