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