Skip to main content

abuse_contact/
rdap.rs

1//! The RDAP source: the response types, and how to find the abuse contact in one.
2//!
3//! This module holds the smallest part of RDAP that an abuse lookup needs: entities,
4//! their jCard, and the links. Fields this crate does not read are ignored, so a new
5//! field on the registry side does not turn a working lookup into a parse error.
6
7use std::net::IpAddr;
8use std::ops::RangeInclusive;
9
10use serde::Deserialize;
11
12use crate::contact::{Contact, EmailAddress, Scope, Source};
13
14/// An RDAP response, cut down to what an abuse lookup reads.
15#[derive(Clone, Debug, Default, Deserialize)]
16pub struct Response {
17    /// The contacts attached to this object.
18    #[serde(default)]
19    pub entities: Vec<Entity>,
20    /// Links to other records, including the registrar record for a domain.
21    #[serde(default)]
22    pub links: Vec<Link>,
23    /// The first address of the network, on an IP record.
24    ///
25    /// This is kept as it came, so a value that is not a string does not stop the
26    /// rest of the record from being read. [`Response::range`] reads it.
27    #[serde(rename = "startAddress")]
28    pub start_address: Option<serde_json::Value>,
29    /// The last address of the network, on an IP record.
30    #[serde(rename = "endAddress")]
31    pub end_address: Option<serde_json::Value>,
32}
33
34/// A contact on an RDAP object.
35///
36/// An entity holds entities of its own. The abuse contact sits at the top level at
37/// some registries and under the registrant or the registrar at others, so a reader
38/// must walk the whole tree.
39#[derive(Clone, Debug, Default, Deserialize)]
40pub struct Entity {
41    /// What this entity is: `abuse`, `registrant`, `technical`, and others.
42    #[serde(default)]
43    pub roles: Vec<String>,
44    /// The registry handle, useful in a log line.
45    pub handle: Option<String>,
46    /// The contact details, as jCard.
47    #[serde(rename = "vcardArray")]
48    pub vcard_array: Option<serde_json::Value>,
49    /// Entities under this one.
50    #[serde(default)]
51    pub entities: Vec<Entity>,
52}
53
54/// A link from one RDAP record to another.
55#[derive(Clone, Debug, Deserialize)]
56pub struct Link {
57    /// What the target is to this record. `related` points at the registrar.
58    pub rel: Option<String>,
59    /// Where the target is.
60    pub href: Option<String>,
61}
62
63/// Reads an address out of a JSON value, when the value is a string that holds one.
64fn address(value: &serde_json::Value) -> Option<IpAddr> {
65    value.as_str()?.parse().ok()
66}
67
68/// The role an entity carries when it accepts abuse reports.
69const ABUSE_ROLE: &str = "abuse";
70
71impl Response {
72    /// Returns the addresses the network covers, on an IP record.
73    ///
74    /// Returns `None` when the record gives no range, or a range that cannot be read:
75    /// an end that is not an address, two ends in different families, or an end
76    /// before the start.
77    pub fn range(&self) -> Option<RangeInclusive<IpAddr>> {
78        let start = address(self.start_address.as_ref()?)?;
79        let end = address(self.end_address.as_ref()?)?;
80
81        let same_family = start.is_ipv4() == end.is_ipv4();
82        (same_family && start <= end).then_some(start..=end)
83    }
84
85    /// Returns the registrar record for a domain, when the registry names one.
86    ///
87    /// The registry record usually carries the abuse address of the registrar, under
88    /// the registrar entity. Read the registrar record when you want the details the
89    /// registry leaves out, such as the abuse telephone number. It is a second
90    /// request, so make it only when you need it.
91    pub fn related_href(&self) -> Option<&str> {
92        self.links
93            .iter()
94            .find(|link| link.rel.as_deref() == Some("related"))
95            .and_then(|link| link.href.as_deref())
96    }
97
98    /// Returns every abuse contact in the response, each one time.
99    ///
100    /// A value that is not an address, or that is a registry placeholder, is dropped.
101    ///
102    /// One registry puts the same abuse entity at the top level and again under the
103    /// registrant, so a walk of the tree finds it twice. The repeat says nothing, and
104    /// this method drops it.
105    pub fn abuse_contacts(&self, scope: Scope, server: &str) -> Vec<Contact> {
106        let mut contacts = Vec::new();
107        collect(&self.entities, scope, server, &mut contacts);
108
109        let mut seen = std::collections::HashSet::new();
110        contacts.retain(|contact| seen.insert(contact.email.clone()));
111        contacts
112    }
113}
114
115/// Walks the entity tree and collects the addresses of every entity with the abuse
116/// role.
117fn collect(entities: &[Entity], scope: Scope, server: &str, out: &mut Vec<Contact>) {
118    for entity in entities {
119        if entity.roles.iter().any(|role| role == ABUSE_ROLE) {
120            out.extend(
121                emails(entity.vcard_array.as_ref())
122                    .into_iter()
123                    .filter_map(|value| EmailAddress::new(value).ok())
124                    .map(|email| Contact {
125                        email,
126                        scope,
127                        source: Source::Rdap {
128                            server: server.to_owned(),
129                        },
130                    }),
131            );
132        }
133
134        collect(&entity.entities, scope, server, out);
135    }
136}
137
138/// The preference of a vCard property that names none.
139///
140/// RFC 6350 gives such a property the lowest preference, so it sorts last.
141const DEFAULT_PREF: u32 = 100;
142
143/// Reads the email values out of a jCard, most preferred first.
144///
145/// jCard is a pair: the string `vcard`, then the properties. Each property is an
146/// array where the name is first, the parameters second, and the value fourth, as in
147/// `["email", {"pref": "1"}, "text", "abuse@example.com"]`. A property in another
148/// shape is skipped rather than read by position, because position is all jCard
149/// gives.
150///
151/// One registry lists a help desk and an abuse mailbox on the same entity and marks
152/// the abuse mailbox `pref: 1`. Reading in document order gives the help desk, so the
153/// values come back in preference order instead.
154fn emails(vcard_array: Option<&serde_json::Value>) -> Vec<&str> {
155    let Some(properties) = vcard_array
156        .and_then(|v| v.get(1))
157        .and_then(|v| v.as_array())
158    else {
159        return Vec::new();
160    };
161
162    let mut found: Vec<(u32, &str)> = properties
163        .iter()
164        .filter_map(|property| property.as_array())
165        .filter(|property| property.first().and_then(|n| n.as_str()) == Some("email"))
166        .filter_map(|property| {
167            let value = property.get(3).and_then(|value| value.as_str())?;
168            Some((preference(property.get(1)), value))
169        })
170        .collect();
171
172    // A stable sort keeps document order among values of equal preference.
173    found.sort_by_key(|(pref, _)| *pref);
174    found.into_iter().map(|(_, value)| value).collect()
175}
176
177/// Returns the `pref` parameter of a jCard property.
178///
179/// Registries write it as a string and as a number, so both are read.
180fn preference(parameters: Option<&serde_json::Value>) -> u32 {
181    let Some(pref) = parameters.and_then(|p| p.get("pref")) else {
182        return DEFAULT_PREF;
183    };
184
185    if let Some(text) = pref.as_str() {
186        return text.parse().unwrap_or(DEFAULT_PREF);
187    }
188
189    pref.as_u64()
190        .and_then(|n| u32::try_from(n).ok())
191        .unwrap_or(DEFAULT_PREF)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    /// An ARIN answer for an IP address. The abuse entity is under the registrant.
199    const ARIN_IP: &str = r#"{
200      "entities": [{
201        "handle": "GOGL",
202        "roles": ["registrant"],
203        "vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Google LLC"]]],
204        "entities": [{
205          "handle": "ABUSE5250-ARIN",
206          "roles": ["abuse"],
207          "vcardArray": ["vcard", [
208            ["fn", {}, "text", "Abuse"],
209            ["email", {}, "text", "network-abuse@google.com"],
210            ["tel", {"type": ["work", "voice"]}, "text", "+1-650-253-0000"]
211          ]]
212        }, {
213          "handle": "ZG39-ARIN",
214          "roles": ["administrative", "technical"],
215          "vcardArray": ["vcard", [["email", {}, "text", "arin-contact@google.com"]]]
216        }]
217      }]
218    }"#;
219
220    /// A RIPE answer for an IP address. The abuse entity is at the top level.
221    const RIPE_IP: &str = r#"{
222      "entities": [{
223        "handle": "MDIR-RIPE",
224        "roles": ["administrative"],
225        "vcardArray": ["vcard", [["fn", {}, "text", "Managing Director"]]]
226      }, {
227        "handle": "OPS4-RIPE",
228        "roles": ["abuse"],
229        "vcardArray": ["vcard", [
230          ["fn", {}, "text", "RIPE NCC Operations"],
231          ["email", {}, "text", "abuse@ripe.net"]
232        ]]
233      }]
234    }"#;
235
236    /// A registry answer for a domain. The abuse entity is under the registrar.
237    const REGISTRY_DOMAIN: &str = r#"{
238      "entities": [{
239        "roles": ["registrar"],
240        "vcardArray": ["vcard", [["fn", {}, "text", "Cloudflare, Inc."]]],
241        "entities": [{
242          "roles": ["abuse"],
243          "vcardArray": ["vcard", [["email", {}, "text", "registrar-abuse@cloudflare.com"]]]
244        }]
245      }],
246      "links": [
247        {"rel": "self", "href": "https://rdap.verisign.com/com/v1/domain/cloudflare.com"},
248        {"rel": "related", "href": "https://rdap.cloudflare.com/rdap/v1/domain/CLOUDFLARE.COM"}
249      ]
250    }"#;
251
252    /// A registrar answer. The abuse entity is under the registrar, and every other
253    /// contact is redacted.
254    const REGISTRAR_DOMAIN: &str = r#"{
255      "entities": [{
256        "roles": ["registrar"],
257        "vcardArray": ["vcard", [["email", {}, "text", "registrar-admin@cloudflare.com"]]],
258        "entities": [{
259          "roles": ["abuse"],
260          "vcardArray": ["vcard", [
261            ["fn", {}, "text", "Cloudflare Registrar Abuse"],
262            ["email", {}, "text", "registrar-abuse@cloudflare.com"]
263          ]]
264        }]
265      }, {
266        "roles": ["registrant"],
267        "vcardArray": ["vcard", [
268          ["fn", {}, "text", "DATA REDACTED"],
269          ["email", {}, "text", "DATA REDACTED"]
270        ]]
271      }]
272    }"#;
273
274    fn parse(json: &str) -> Response {
275        serde_json::from_str(json).unwrap()
276    }
277
278    fn emails_of(response: &Response, scope: Scope) -> Vec<String> {
279        response
280            .abuse_contacts(scope, "rdap.example.net")
281            .iter()
282            .map(|c| c.email.to_string())
283            .collect()
284    }
285
286    #[test]
287    fn finds_an_abuse_entity_nested_under_the_registrant() {
288        assert_eq!(
289            emails_of(&parse(ARIN_IP), Scope::Network),
290            ["network-abuse@google.com"]
291        );
292    }
293
294    #[test]
295    fn finds_an_abuse_entity_at_the_top_level() {
296        assert_eq!(
297            emails_of(&parse(RIPE_IP), Scope::Network),
298            ["abuse@ripe.net"]
299        );
300    }
301
302    #[test]
303    fn ignores_entities_without_the_abuse_role() {
304        let contacts = emails_of(&parse(ARIN_IP), Scope::Network);
305
306        assert!(!contacts.contains(&"arin-contact@google.com".to_owned()));
307    }
308
309    #[test]
310    fn finds_an_abuse_entity_nested_under_the_registrar() {
311        assert_eq!(
312            emails_of(&parse(REGISTRY_DOMAIN), Scope::Registrar),
313            ["registrar-abuse@cloudflare.com"]
314        );
315    }
316
317    #[test]
318    fn returns_a_repeated_abuse_entity_one_time() {
319        let response = parse(
320            r#"{"entities":[
321                 {"roles":["abuse"],"vcardArray":["vcard",[["email",{},"text","a@example.com"]]]},
322                 {"roles":["registrant"],"entities":[
323                   {"roles":["abuse"],"vcardArray":["vcard",[["email",{},"text","a@example.com"]]]}
324                 ]}
325               ]}"#,
326        );
327
328        assert_eq!(emails_of(&response, Scope::Network), ["a@example.com"]);
329    }
330
331    #[test]
332    fn a_registry_domain_record_points_at_the_registrar() {
333        assert_eq!(
334            parse(REGISTRY_DOMAIN).related_href(),
335            Some("https://rdap.cloudflare.com/rdap/v1/domain/CLOUDFLARE.COM")
336        );
337    }
338
339    #[test]
340    fn finds_the_registrar_abuse_contact_and_drops_the_redacted_one() {
341        assert_eq!(
342            emails_of(&parse(REGISTRAR_DOMAIN), Scope::Registrar),
343            ["registrar-abuse@cloudflare.com"]
344        );
345    }
346
347    #[test]
348    fn records_which_server_answered() {
349        let contacts = parse(RIPE_IP).abuse_contacts(Scope::Network, "rdap.db.ripe.net");
350
351        assert_eq!(
352            contacts[0].source,
353            Source::Rdap {
354                server: "rdap.db.ripe.net".to_owned()
355            }
356        );
357    }
358
359    #[test]
360    fn reads_the_preferred_address_first() {
361        // APNIC lists a help desk and an abuse mailbox, and marks the abuse one.
362        let response = parse(
363            r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
364                 ["email", {}, "text", "helpdesk@apnic.net"],
365                 ["email", {"pref": "1"}, "text", "abuse@apnic.net"]
366               ]]}]}"#,
367        );
368
369        assert_eq!(
370            emails_of(&response, Scope::Network),
371            ["abuse@apnic.net", "helpdesk@apnic.net"]
372        );
373    }
374
375    #[test]
376    fn reads_a_numeric_pref() {
377        let response = parse(
378            r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
379                 ["email", {}, "text", "second@example.com"],
380                 ["email", {"pref": 1}, "text", "first@example.com"]
381               ]]}]}"#,
382        );
383
384        assert_eq!(
385            emails_of(&response, Scope::Network),
386            ["first@example.com", "second@example.com"]
387        );
388    }
389
390    #[test]
391    fn keeps_document_order_when_no_property_names_a_preference() {
392        let response = parse(
393            r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
394                 ["email", {}, "text", "one@example.com"],
395                 ["email", {}, "text", "two@example.com"]
396               ]]}]}"#,
397        );
398
399        assert_eq!(
400            emails_of(&response, Scope::Network),
401            ["one@example.com", "two@example.com"]
402        );
403    }
404
405    #[test]
406    fn finds_an_entity_that_carries_the_abuse_role_beside_another() {
407        // registro.br marks one entity both technical and abuse.
408        let response = parse(
409            r#"{"entities":[{"roles":["technical","abuse"],"vcardArray":["vcard",[
410                 ["email", {}, "text", "noc@example.com"]
411               ]]}]}"#,
412        );
413
414        assert_eq!(emails_of(&response, Scope::Network), ["noc@example.com"]);
415    }
416
417    #[test]
418    fn an_abuse_entity_without_an_address_gives_nothing() {
419        // registro.br publishes an abuse entity whose jCard holds only a name.
420        let response = parse(
421            r#"{"entities":[{"roles":["technical","abuse"],"vcardArray":["vcard",[
422                 ["fn", {}, "text", "Frederico Augusto de Carvalho Neves"],
423                 ["lang", {}, "language-tag", "pt"]
424               ]]}]}"#,
425        );
426
427        assert_eq!(emails_of(&response, Scope::Network), Vec::<String>::new());
428    }
429
430    #[test]
431    fn reads_an_empty_response() {
432        let response = parse("{}");
433
434        assert_eq!(response.abuse_contacts(Scope::Network, "x"), Vec::new());
435        assert_eq!(response.related_href(), None);
436    }
437
438    #[test]
439    fn skips_a_jcard_property_in_a_shape_it_does_not_know() {
440        let response = parse(
441            r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
442                 ["email"],
443                 ["email", {}, "text", ["abuse@example.com"]],
444                 ["email", {}, "text", "good@example.com"]
445               ]]}]}"#,
446        );
447
448        assert_eq!(emails_of(&response, Scope::Network), ["good@example.com"]);
449    }
450
451    #[test]
452    fn reads_the_range_of_a_network() {
453        let response =
454            parse(r#"{"startAddress": "8.8.8.0", "endAddress": "8.8.8.255", "entities": []}"#);
455
456        assert_eq!(
457            response.range(),
458            Some("8.8.8.0".parse().unwrap()..="8.8.8.255".parse().unwrap())
459        );
460    }
461
462    #[test]
463    fn a_range_that_cannot_be_read_is_none() {
464        for json in [
465            // A domain record carries no range.
466            r#"{}"#,
467            r#"{"startAddress": "8.8.8.0"}"#,
468            r#"{"startAddress": "8.8.8.0", "endAddress": "not an address"}"#,
469            // The two ends are in different families.
470            r#"{"startAddress": "8.8.8.0", "endAddress": "2001:db8::ff"}"#,
471            // The range ends before it starts.
472            r#"{"startAddress": "8.8.8.255", "endAddress": "8.8.8.0"}"#,
473            // The field is not a string. The rest of the record is still read.
474            r#"{"startAddress": 1, "endAddress": 2}"#,
475        ] {
476            assert_eq!(parse(json).range(), None, "{json}");
477        }
478    }
479}