Skip to main content

netget/server/rdap/
query.rs

1//! RFC 9082 query paths and RFC 9083 response shapes, shared by both roles.
2use anyhow::{bail, ensure, Context, Result};
3use serde_json::{json, Map, Value};
4use std::net::IpAddr;
5
6pub const MAX_BODY_BYTES: usize = 1024 * 1024;
7pub const MAX_JSON_NODES: usize = 50_000;
8pub const MAX_JSON_DEPTH: usize = 32;
9pub const MAX_VALUE_BYTES: usize = 255;
10pub const MAX_SEARCH_RESULTS: usize = 1000;
11pub const CONFORMANCE: &str = "rdap_level_0";
12pub const MEDIA_TYPE: &str = "application/rdap+json";
13/// Path prefix RDAP queries live under when none is configured.
14pub const DEFAULT_BASE_PATH: &str = "/";
15
16/// One parsed RDAP query.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum Query {
19    Lookup {
20        kind: Lookup,
21        value: String,
22    },
23    Search {
24        kind: Search,
25        parameter: String,
26        value: String,
27    },
28    Help,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Lookup {
33    Domain,
34    Nameserver,
35    Ip,
36    Autnum,
37    Entity,
38}
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum Search {
41    Domains,
42    Nameservers,
43    Entities,
44}
45
46impl Lookup {
47    pub fn path(self) -> &'static str {
48        match self {
49            Self::Domain => "domain",
50            Self::Nameserver => "nameserver",
51            Self::Ip => "ip",
52            Self::Autnum => "autnum",
53            Self::Entity => "entity",
54        }
55    }
56    /// The `objectClassName` RFC 9083 requires of the answer.
57    pub fn class(self) -> &'static str {
58        match self {
59            Self::Domain => "domain",
60            Self::Nameserver => "nameserver",
61            Self::Ip => "ip network",
62            Self::Autnum => "autnum",
63            Self::Entity => "entity",
64        }
65    }
66    pub fn from_name(name: &str) -> Option<Self> {
67        Some(match name {
68            "domain" => Self::Domain,
69            "nameserver" => Self::Nameserver,
70            "ip" => Self::Ip,
71            "autnum" => Self::Autnum,
72            "entity" => Self::Entity,
73            _ => return None,
74        })
75    }
76}
77
78impl Search {
79    pub fn path(self) -> &'static str {
80        match self {
81            Self::Domains => "domains",
82            Self::Nameservers => "nameservers",
83            Self::Entities => "entities",
84        }
85    }
86    pub fn class(self) -> &'static str {
87        match self {
88            Self::Domains => "domain",
89            Self::Nameservers => "nameserver",
90            Self::Entities => "entity",
91        }
92    }
93    /// RFC 9083 ยง8 result member name.
94    pub fn member(self) -> &'static str {
95        match self {
96            Self::Domains => "domainSearchResults",
97            Self::Nameservers => "nameserverSearchResults",
98            Self::Entities => "entitySearchResults",
99        }
100    }
101    pub fn parameters(self) -> &'static [&'static str] {
102        match self {
103            Self::Domains => &["name", "nsLdhName", "nsIp"],
104            Self::Nameservers => &["name", "ip"],
105            Self::Entities => &["fn", "handle"],
106        }
107    }
108    pub fn from_name(name: &str) -> Option<Self> {
109        Some(match name {
110            "domains" => Self::Domains,
111            "nameservers" => Self::Nameservers,
112            "entities" => Self::Entities,
113            _ => return None,
114        })
115    }
116}
117
118fn percent_decode(text: &str) -> Result<String> {
119    let bytes = text.as_bytes();
120    let mut out = Vec::with_capacity(bytes.len());
121    let mut i = 0;
122    while i < bytes.len() {
123        if bytes[i] == b'%' {
124            let hex = text
125                .get(i + 1..i + 3)
126                .context("truncated percent-encoding")?;
127            out.push(u8::from_str_radix(hex, 16).context("invalid percent-encoding")?);
128            i += 3;
129        } else if bytes[i] == b'+' {
130            out.push(b'+');
131            i += 1;
132        } else {
133            out.push(bytes[i]);
134            i += 1;
135        }
136    }
137    String::from_utf8(out).context("query value is not UTF-8")
138}
139
140pub fn percent_encode(text: &str) -> String {
141    let mut out = String::new();
142    for b in text.bytes() {
143        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'*' | b':') {
144            out.push(b as char);
145        } else {
146            out.push_str(&format!("%{b:02X}"));
147        }
148    }
149    out
150}
151
152fn plain(value: &str) -> Result<()> {
153    ensure!(
154        !value.is_empty() && value.len() <= MAX_VALUE_BYTES,
155        "query value must be 1..=255 bytes"
156    );
157    ensure!(
158        !value.chars().any(|c| c.is_control()),
159        "query value contains a control character"
160    );
161    Ok(())
162}
163
164/// A domain or nameserver name: labels of letters/digits/hyphens (or U-labels), no empty
165/// label, a trailing dot dropped, compared lower-case.
166fn name(value: &str, wildcard: bool) -> Result<String> {
167    plain(value)?;
168    let v = value.strip_suffix('.').unwrap_or(value).to_lowercase();
169    ensure!(v.len() <= 253 && !v.is_empty(), "name is too long or empty");
170    for (i, label) in v.split('.').enumerate() {
171        let star = wildcard && label.contains('*');
172        ensure!(
173            !label.is_empty() && label.len() <= 63,
174            "empty or over-long label"
175        );
176        ensure!(
177            label
178                .chars()
179                .all(|c| c.is_alphanumeric() || c == '-' || (star && c == '*')),
180            "label '{label}' has a character outside letters, digits and hyphen"
181        );
182        ensure!(
183            !star || i == 0 || label == "*",
184            "a wildcard may only stand in the first label"
185        );
186    }
187    Ok(v)
188}
189
190fn ip(value: &str) -> Result<String> {
191    plain(value)?;
192    let (addr, len) = match value.split_once('/') {
193        Some((a, l)) => (
194            a,
195            Some(l.parse::<u8>().context("prefix length must be a number")?),
196        ),
197        None => (value, None),
198    };
199    let parsed: IpAddr = addr.parse().context("not an IP address")?;
200    let max = if parsed.is_ipv4() { 32 } else { 128 };
201    Ok(match len {
202        Some(l) => {
203            ensure!(l <= max, "prefix length exceeds {max}");
204            format!("{parsed}/{l}")
205        }
206        None => parsed.to_string(),
207    })
208}
209
210/// Parse a request path (after the base path) and its query string.
211pub fn parse(path: &str, query: Option<&str>) -> Result<Query> {
212    let path = path.trim_start_matches('/');
213    let segments: Vec<&str> = path.split('/').collect();
214    let query_pairs = || -> Result<Vec<(String, String)>> {
215        let mut pairs = Vec::new();
216        for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
217            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
218            pairs.push((percent_decode(k)?, percent_decode(v)?));
219            ensure!(pairs.len() <= 8, "too many query parameters");
220        }
221        Ok(pairs)
222    };
223    match segments.as_slice() {
224        ["help"] => Ok(Query::Help),
225        [kind, rest @ ..] if Lookup::from_name(kind).is_some() && !rest.is_empty() => {
226            let kind = Lookup::from_name(kind).expect("checked");
227            let raw = match (kind, rest) {
228                (Lookup::Ip, [a, l]) => format!("{}/{}", percent_decode(a)?, percent_decode(l)?),
229                (_, [v]) => percent_decode(v)?,
230                _ => bail!("too many path segments"),
231            };
232            let value = match kind {
233                Lookup::Domain | Lookup::Nameserver => name(&raw, false)?,
234                Lookup::Ip => ip(&raw)?,
235                Lookup::Autnum => raw
236                    .parse::<u32>()
237                    .context("autnum must be a 32-bit number")?
238                    .to_string(),
239                Lookup::Entity => {
240                    plain(&raw)?;
241                    raw
242                }
243            };
244            Ok(Query::Lookup { kind, value })
245        }
246        [kind] if Search::from_name(kind).is_some() => {
247            let kind = Search::from_name(kind).expect("checked");
248            let pairs = query_pairs()?;
249            ensure!(pairs.len() == 1, "a search takes exactly one parameter");
250            let (parameter, raw) = pairs.into_iter().next().expect("one");
251            ensure!(
252                kind.parameters().contains(&parameter.as_str()),
253                "{} searches take {:?}",
254                kind.path(),
255                kind.parameters()
256            );
257            let value = match parameter.as_str() {
258                "name" | "nsLdhName" => name(&raw, true)?,
259                "nsIp" | "ip" => ip(&raw)?,
260                _ => {
261                    plain(&raw)?;
262                    raw
263                }
264            };
265            Ok(Query::Search {
266                kind,
267                parameter,
268                value,
269            })
270        }
271        _ => bail!("not an RFC 9082 query path"),
272    }
273}
274
275impl Query {
276    /// Event data for the handler.
277    pub fn to_event(&self) -> Value {
278        match self {
279            Query::Lookup { kind, value } => json!({"query_type": kind.path(), "value": value}),
280            Query::Search {
281                kind,
282                parameter,
283                value,
284            } => json!({"query_type": kind.path(), "search_parameter": parameter, "value": value}),
285            Query::Help => json!({"query_type": "help"}),
286        }
287    }
288    /// The relative request path a client sends.
289    pub fn path(&self) -> String {
290        match self {
291            Query::Lookup {
292                kind: Lookup::Ip,
293                value,
294            } => format!("ip/{value}"),
295            Query::Lookup { kind, value } => format!("{}/{}", kind.path(), percent_encode(value)),
296            Query::Search {
297                kind,
298                parameter,
299                value,
300            } => format!("{}?{}={}", kind.path(), parameter, percent_encode(value)),
301            Query::Help => "help".into(),
302        }
303    }
304    /// Build from a client action.
305    pub fn from_action(v: &Value) -> Result<Self> {
306        let kind = v["query_type"].as_str().context("query_type required")?;
307        if kind == "help" {
308            return Ok(Query::Help);
309        }
310        let value = v["value"].as_str().context("value required")?;
311        if let Some(lookup) = Lookup::from_name(kind) {
312            let path = if lookup == Lookup::Ip {
313                format!("ip/{value}")
314            } else {
315                format!("{kind}/{}", percent_encode(value))
316            };
317            return parse(&path, None);
318        }
319        let search =
320            Search::from_name(kind).with_context(|| format!("unknown query_type '{kind}'"))?;
321        let parameter = v["search_parameter"]
322            .as_str()
323            .unwrap_or(search.parameters()[0]);
324        parse(
325            search.path(),
326            Some(&format!("{parameter}={}", percent_encode(value))),
327        )
328    }
329}
330
331pub fn json_ok(value: &Value) -> bool {
332    crate::utils::json_budget::within_budget(value, MAX_BODY_BYTES, MAX_JSON_NODES, MAX_JSON_DEPTH)
333}
334
335fn conformance(obj: &mut Map<String, Value>) -> Result<()> {
336    let mut list: Vec<Value> = match obj.remove("rdapConformance") {
337        None => Vec::new(),
338        Some(Value::Array(a)) => a,
339        Some(_) => bail!("rdapConformance must be an array of strings"),
340    };
341    ensure!(
342        list.iter().all(Value::is_string) && list.len() <= 64,
343        "rdapConformance must be an array of strings"
344    );
345    if !list.iter().any(|v| v == CONFORMANCE) {
346        list.insert(0, json!(CONFORMANCE));
347    }
348    obj.insert("rdapConformance".into(), Value::Array(list));
349    Ok(())
350}
351
352fn class_is(obj: &Value, expected: &str) -> Result<()> {
353    let got = obj.get("objectClassName").and_then(Value::as_str);
354    ensure!(
355        got == Some(expected),
356        "objectClassName must be \"{expected}\", not {got:?}"
357    );
358    Ok(())
359}
360
361/// What the server sends for a handler's answer.
362#[derive(Debug, PartialEq)]
363pub enum Answer {
364    Body { status: u16, body: Value },
365    Redirect(String),
366}
367
368pub fn error_body(code: u16, title: &str, description: &[String]) -> Value {
369    let mut body = json!({"rdapConformance": [CONFORMANCE], "errorCode": code, "title": title});
370    if !description.is_empty() {
371        body["description"] = json!(description);
372    }
373    body
374}
375
376/// Turn a validated handler answer into the response for this query.
377pub fn answer(query: &Query, action: &Value) -> Result<Answer> {
378    let obj = action.as_object().context("response must be an object")?;
379    for k in obj.keys() {
380        ensure!(
381            matches!(
382                k.as_str(),
383                "type" | "object" | "results" | "not_found" | "error" | "redirect"
384            ),
385            "unknown response field '{k}'"
386        );
387    }
388    let present: Vec<&str> = ["object", "results", "not_found", "error", "redirect"]
389        .into_iter()
390        .filter(|k| obj.get(*k).is_some_and(|v| !v.is_null()))
391        .collect();
392    ensure!(
393        present.len() == 1,
394        "supply exactly one of object, results, not_found, error, redirect"
395    );
396    match present[0] {
397        "not_found" => {
398            ensure!(obj["not_found"] == true, "not_found must be true");
399            Ok(Answer::Body {
400                status: 404,
401                body: error_body(404, "Not Found", &[]),
402            })
403        }
404        "error" => {
405            let e = obj["error"]
406                .as_object()
407                .context("error must be an object")?;
408            let code = e
409                .get("code")
410                .and_then(Value::as_u64)
411                .context("error.code required")?;
412            ensure!(
413                matches!(code, 400 | 403 | 404 | 422 | 429 | 500 | 501 | 503),
414                "error.code must be 400, 403, 404, 422, 429, 500, 501 or 503"
415            );
416            let title = e.get("title").and_then(Value::as_str).unwrap_or("Error");
417            ensure!(title.len() <= 256, "error.title too long");
418            let description: Vec<String> = match e.get("description") {
419                None => Vec::new(),
420                Some(Value::Array(a)) => a
421                    .iter()
422                    .map(|v| {
423                        v.as_str()
424                            .map(str::to_owned)
425                            .context("description entries must be strings")
426                    })
427                    .collect::<Result<_>>()?,
428                Some(Value::String(s)) => vec![s.clone()],
429                _ => bail!("error.description must be a string or array of strings"),
430            };
431            ensure!(
432                description.len() <= 16 && description.iter().all(|d| d.len() <= 1024),
433                "error.description bound"
434            );
435            Ok(Answer::Body {
436                status: code as u16,
437                body: error_body(code as u16, title, &description),
438            })
439        }
440        "redirect" => {
441            let url = obj["redirect"]
442                .as_str()
443                .context("redirect must be a URL string")?;
444            ensure!(
445                url.len() <= 2048 && (url.starts_with("https://") || url.starts_with("http://")),
446                "redirect must be an http(s) URL"
447            );
448            ensure!(
449                !url.chars().any(|c| c.is_whitespace() || c.is_control()),
450                "redirect URL has whitespace or controls"
451            );
452            ensure!(
453                matches!(query, Query::Lookup { .. }),
454                "only lookups are redirected"
455            );
456            Ok(Answer::Redirect(url.to_owned()))
457        }
458        "object" => {
459            let mut object = obj["object"].clone();
460            ensure!(json_ok(&object), "object exceeds the RDAP response bounds");
461            let map = object
462                .as_object_mut()
463                .context("object must be a JSON object")?;
464            match query {
465                Query::Lookup { kind, .. } => class_is(&Value::Object(map.clone()), kind.class())?,
466                Query::Help => ensure!(
467                    map.get("notices").is_some_and(Value::is_array),
468                    "a help answer carries notices"
469                ),
470                Query::Search { .. } => bail!("a search is answered with results, not object"),
471            }
472            conformance(map)?;
473            Ok(Answer::Body {
474                status: 200,
475                body: object,
476            })
477        }
478        _ => {
479            let Query::Search { kind, .. } = query else {
480                bail!("results answer searches only")
481            };
482            let results = obj["results"]
483                .as_array()
484                .context("results must be an array")?;
485            ensure!(
486                results.len() <= MAX_SEARCH_RESULTS,
487                "too many search results"
488            );
489            for r in results {
490                class_is(r, kind.class())?;
491            }
492            let mut body = Map::new();
493            body.insert(kind.member().into(), Value::Array(results.clone()));
494            conformance(&mut body)?;
495            let body = Value::Object(body);
496            ensure!(json_ok(&body), "results exceed the RDAP response bounds");
497            Ok(Answer::Body { status: 200, body })
498        }
499    }
500}
501
502/// Client-side check of a 200 response body against the query that produced it.
503pub fn check_response(query: &Query, body: &Value) -> Result<()> {
504    ensure!(json_ok(body), "response exceeds the RDAP bounds");
505    let obj = body
506        .as_object()
507        .context("RDAP response must be a JSON object")?;
508    let conf = obj
509        .get("rdapConformance")
510        .and_then(Value::as_array)
511        .context("RDAP response lacks rdapConformance")?;
512    ensure!(
513        conf.iter().all(Value::is_string),
514        "rdapConformance must hold strings"
515    );
516    match query {
517        Query::Lookup { kind, .. } => class_is(body, kind.class()),
518        Query::Search { kind, .. } => {
519            let results = obj
520                .get(kind.member())
521                .and_then(Value::as_array)
522                .with_context(|| format!("search response lacks {}", kind.member()))?;
523            for r in results {
524                class_is(r, kind.class())?;
525            }
526            Ok(())
527        }
528        Query::Help => Ok(()),
529    }
530}