File
Blob: src/rust/api/dns.rs
| 1 | // Copyright (c) 2026 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | |
| 5 | use jsg_macros::jsg_method; |
| 6 | use jsg_macros::jsg_resource; |
| 7 | use jsg_macros::jsg_struct; |
| 8 | use thiserror::Error; |
| 9 | |
| 10 | #[derive(Debug, Error)] |
| 11 | pub enum DnsParserError { |
| 12 | #[error("Invalid hex string: {0}")] |
| 13 | InvalidHexString(String), |
| 14 | #[error("ParseInt error: {0}")] |
| 15 | ParseIntError(#[from] std::num::ParseIntError), |
| 16 | #[error("Invalid DNS response: {0}")] |
| 17 | InvalidDnsResponse(String), |
| 18 | #[error("unknown dns parser error")] |
| 19 | Unknown, |
| 20 | } |
| 21 | |
| 22 | impl From<DnsParserError> for jsg::Error { |
| 23 | fn from(val: DnsParserError) -> Self { |
| 24 | match val { |
| 25 | DnsParserError::InvalidHexString(msg) | DnsParserError::InvalidDnsResponse(msg) => { |
| 26 | Self::new_error(&msg) |
| 27 | } |
| 28 | DnsParserError::ParseIntError(msg) => Self::new_range_error(msg.to_string()), |
| 29 | DnsParserError::Unknown => Self::new_error("Unknown dns parser error"), |
| 30 | } |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | /// CAA record representation |
| 35 | #[jsg_struct] |
| 36 | #[derive(Debug)] |
| 37 | pub struct CaaRecord { |
| 38 | pub critical: u8, |
| 39 | pub field: String, |
| 40 | pub value: String, |
| 41 | } |
| 42 | |
| 43 | /// NAPTR record representation |
| 44 | #[jsg_struct] |
| 45 | #[derive(Debug)] |
| 46 | pub struct NaptrRecord { |
| 47 | pub flags: String, |
| 48 | pub service: String, |
| 49 | pub regexp: String, |
| 50 | pub replacement: String, |
| 51 | pub order: u32, |
| 52 | pub preference: u32, |
| 53 | } |
| 54 | |
| 55 | /// Given a vector of strings, converts each slice to UTF-8 from HEX. |
| 56 | /// |
| 57 | /// # Errors |
| 58 | /// `DnsParserError::InvalidHexString` |
| 59 | /// `DnsParserError::ParseIntError` |
| 60 | pub fn decode_hex(input: &[&str]) -> Result<Vec<String>, DnsParserError> { |
| 61 | let mut v = Vec::with_capacity(input.len()); |
| 62 | |
| 63 | for slice in input { |
| 64 | let num = u16::from_str_radix(slice, 16)?; |
| 65 | let ch = String::from_utf16(&[num]) |
| 66 | .map_err(|_| DnsParserError::InvalidHexString("Invalid UTF-16 sequence".to_owned()))?; |
| 67 | v.push(ch); |
| 68 | } |
| 69 | |
| 70 | Ok(v) |
| 71 | } |
| 72 | |
| 73 | /// Replacement values needs to be parsed accordingly. |
| 74 | /// |
| 75 | /// It has a similar characteristic to CAA and NAPTR records whereas |
| 76 | /// first character contains the length of the input, and the second character |
| 77 | /// is the starting index of the substring. We need to continue parsing until there |
| 78 | /// are no input left, and later join them using "." |
| 79 | /// |
| 80 | /// It is important that the returning value doesn't end with dot (".") character. |
| 81 | /// |
| 82 | /// # Errors |
| 83 | /// `DnsParserError::InvalidHexString` |
| 84 | /// `DnsParserError::ParseIntError` |
| 85 | pub fn parse_replacement(input: &[&str]) -> jsg::Result<String, DnsParserError> { |
| 86 | if input.is_empty() { |
| 87 | return Ok(String::new()); |
| 88 | } |
| 89 | |
| 90 | let mut output: Vec<String> = vec![]; |
| 91 | let mut length_index = 0; |
| 92 | let mut offset_index = 1; |
| 93 | |
| 94 | // Iterate through each character to parse different frames. |
| 95 | // Each frame starts with the length of the remaining frame. |
| 96 | while length_index < input.len() { |
| 97 | let length = usize::from_str_radix(input[length_index], 16)?; |
| 98 | if length + offset_index > input.len() { |
| 99 | return Err(DnsParserError::InvalidDnsResponse( |
| 100 | "replacement data too short for declared frame length".to_owned(), |
| 101 | )); |
| 102 | } |
| 103 | let subset = input[offset_index..length + offset_index].to_vec(); |
| 104 | let decoded = decode_hex(&subset)?.join(""); |
| 105 | |
| 106 | // We omit the trailing "." from replacements. |
| 107 | // Cloudflare DNS returns "_sip._udp.sip2sip.info." whereas Node.js removes trailing dot |
| 108 | if !decoded.is_empty() { |
| 109 | output.push(decoded); |
| 110 | } |
| 111 | |
| 112 | length_index += subset.len() + 1; |
| 113 | offset_index = length_index + 1; |
| 114 | } |
| 115 | |
| 116 | Ok(output.join(".")) |
| 117 | } |
| 118 | |
| 119 | #[jsg_resource] |
| 120 | pub struct DnsUtil; |
| 121 | |
| 122 | #[jsg_resource] |
| 123 | impl DnsUtil { |
| 124 | pub fn new() -> jsg::Rc<Self> { |
| 125 | jsg::Rc::new(Self {}) |
| 126 | } |
| 127 | |
| 128 | /// Parses an unknown RR format returned from Cloudflare DNS. |
| 129 | /// Specification is available at |
| 130 | /// `<https://datatracker.ietf.org/doc/html/rfc3597>` |
| 131 | /// |
| 132 | /// The format of the record is as follows: |
| 133 | /// \# <length-in-bytes> <bytes-in-hex> |
| 134 | /// \\# 15 00 05 69 73 73 75 65 70 6b 69 2e 67 6f 6f 67 |
| 135 | /// | | | | |
| 136 | /// | | | - Starting point of the actual data |
| 137 | /// | | - Length of the field. |
| 138 | /// | - Number representation of "`is_critical`" |
| 139 | /// - Length of the data |
| 140 | /// |
| 141 | /// Note: Field can be "issuewild", "issue" or "iodef". |
| 142 | /// |
| 143 | /// ``` |
| 144 | /// let record = parse_caa_record("\\# 15 00 05 69 73 73 75 65 70 6b 69 2e 67 6f 6f 67"); |
| 145 | /// assert_eq!(record.critical, false); |
| 146 | /// assert_eq!(record.field, "issue") |
| 147 | /// assert_eq!(record.value, "pki.goog") |
| 148 | /// ``` |
| 149 | /// # Errors |
| 150 | /// `DnsParserError::InvalidHexString` |
| 151 | /// `DnsParserError::ParseIntError` |
| 152 | #[jsg_method] |
| 153 | pub fn parse_caa_record(&self, record: String) -> Result<CaaRecord, DnsParserError> { |
| 154 | // Let's remove "\\#" and the length of data from the beginning of the record |
| 155 | let parts: Vec<_> = record.split_ascii_whitespace().collect(); |
| 156 | if parts.len() < 3 { |
| 157 | return Err(DnsParserError::InvalidDnsResponse( |
| 158 | "CAA record too short: expected at least 3 fields".to_owned(), |
| 159 | )); |
| 160 | } |
| 161 | let data = parts[2..].to_vec(); |
| 162 | if data.len() < 2 { |
| 163 | return Err(DnsParserError::InvalidDnsResponse( |
| 164 | "CAA record data too short: expected critical and prefix length fields".to_owned(), |
| 165 | )); |
| 166 | } |
| 167 | let critical = data[0].parse::<u8>()?; |
| 168 | let prefix_length = data[1].parse::<usize>()?; |
| 169 | |
| 170 | if data.len() < 2 + prefix_length { |
| 171 | return Err(DnsParserError::InvalidDnsResponse(format!( |
| 172 | "CAA record data too short for prefix_length {prefix_length}" |
| 173 | ))); |
| 174 | } |
| 175 | let field = decode_hex(&data[2..prefix_length + 2])?.join(""); |
| 176 | let value = decode_hex(&data[(prefix_length + 2)..])?.join(""); |
| 177 | |
| 178 | // Field can be "issuewild", "issue" or "iodef" |
| 179 | if field != "issuewild" && field != "issue" && field != "iodef" { |
| 180 | return Err(DnsParserError::InvalidDnsResponse(format!( |
| 181 | "Received unknown field '{field}'" |
| 182 | ))); |
| 183 | } |
| 184 | |
| 185 | Ok(CaaRecord { |
| 186 | critical, |
| 187 | field, |
| 188 | value, |
| 189 | }) |
| 190 | } |
| 191 | |
| 192 | /// Parses an unknown RR format returned from Cloudflare DNS. |
| 193 | /// Specification is available at |
| 194 | /// `<https://datatracker.ietf.org/doc/html/rfc3597>` |
| 195 | /// |
| 196 | /// The format of the record is as follows: |
| 197 | /// \# 37 15 b3 08 ae 01 73 0a 6d 79 2d 73 65 72 76 69 63 65 06 72 65 67 65 78 70 0b 72 65 70 6c 61 63 65 6d 65 6e 74 00 |
| 198 | /// |--| |--| | | | |--------------------------| | |--------------| | |--------------------------------| |
| 199 | /// | | | | | | | | | - Replacement |
| 200 | /// | | | | | | | | - Length of first part of the replacement |
| 201 | /// | | | | | | | - Regexp |
| 202 | /// | | | | | | - Regexp length |
| 203 | /// | | | | | - Service |
| 204 | /// | | | | - Length of service |
| 205 | /// | | | - Flag |
| 206 | /// | | - Length of flags |
| 207 | /// | - Preference |
| 208 | /// - Order |
| 209 | /// |
| 210 | /// ``` |
| 211 | /// let record = parse_naptr_record("\\# 37 15 b3 08 ae 01 73 0a 6d 79 2d 73 65 72 76 69 63 65 06 72 65 67 65 78 70 0b 72 65 70 6c 61 63 65 6d 65 6e 74 00"); |
| 212 | /// assert_eq!(record.flags, "s"); |
| 213 | /// assert_eq!(record.service, "my-service"); |
| 214 | /// assert_eq!(record.regexp, "regexp"); |
| 215 | /// assert_eq!(record.replacement, "replacement"); |
| 216 | /// assert_eq!(record.order, 5555); |
| 217 | /// assert_eq!(record.preference, 2222); |
| 218 | /// ``` |
| 219 | /// |
| 220 | /// # Errors |
| 221 | /// `DnsParserError::InvalidHexString` |
| 222 | /// `DnsParserError::ParseIntError` |
| 223 | #[jsg_method] |
| 224 | pub fn parse_naptr_record(&self, record: String) -> jsg::Result<NaptrRecord, DnsParserError> { |
| 225 | let parts: Vec<_> = record.split_ascii_whitespace().collect(); |
| 226 | if parts.len() < 2 { |
| 227 | return Err(DnsParserError::InvalidDnsResponse( |
| 228 | "NAPTR record too short".to_owned(), |
| 229 | )); |
| 230 | } |
| 231 | let data = parts[1..].to_vec(); |
| 232 | |
| 233 | // Need at least: length(1) + order(2) + preference(2) + flag_length(1) = 6 fields |
| 234 | if data.len() < 6 { |
| 235 | return Err(DnsParserError::InvalidDnsResponse( |
| 236 | "NAPTR record data too short: expected at least 6 fields".to_owned(), |
| 237 | )); |
| 238 | } |
| 239 | |
| 240 | let order_str = data[1..3].to_vec(); |
| 241 | let order = u32::from_str_radix(&order_str.join(""), 16)?; |
| 242 | let preference_str = data[3..5].to_vec(); |
| 243 | let preference = u32::from_str_radix(&preference_str.join(""), 16)?; |
| 244 | |
| 245 | let flag_length = usize::from_str_radix(data[5], 16)?; |
| 246 | let flag_offset = 6; |
| 247 | if data.len() < flag_offset + flag_length + 1 { |
| 248 | return Err(DnsParserError::InvalidDnsResponse( |
| 249 | "NAPTR record too short for flags field".to_owned(), |
| 250 | )); |
| 251 | } |
| 252 | let flags = decode_hex(&data[flag_offset..flag_length + flag_offset])?.join(""); |
| 253 | |
| 254 | let service_length = usize::from_str_radix(data[flag_offset + flag_length], 16)?; |
| 255 | let service_offset = flag_offset + flag_length + 1; |
| 256 | if data.len() < service_offset + service_length + 1 { |
| 257 | return Err(DnsParserError::InvalidDnsResponse( |
| 258 | "NAPTR record too short for service field".to_owned(), |
| 259 | )); |
| 260 | } |
| 261 | let service = decode_hex(&data[service_offset..service_length + service_offset])?.join(""); |
| 262 | |
| 263 | let regexp_length = usize::from_str_radix(data[service_offset + service_length], 16)?; |
| 264 | let regexp_offset = service_offset + service_length + 1; |
| 265 | if data.len() < regexp_offset + regexp_length { |
| 266 | return Err(DnsParserError::InvalidDnsResponse( |
| 267 | "NAPTR record too short for regexp field".to_owned(), |
| 268 | )); |
| 269 | } |
| 270 | let regexp = decode_hex(&data[regexp_offset..regexp_length + regexp_offset])?.join(""); |
| 271 | |
| 272 | let replacement = parse_replacement(&data[regexp_offset + regexp_length..])?; |
| 273 | |
| 274 | Ok(NaptrRecord { |
| 275 | flags, |
| 276 | service, |
| 277 | regexp, |
| 278 | replacement, |
| 279 | order, |
| 280 | preference, |
| 281 | }) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | #[cfg(test)] |
| 286 | mod tests { |
| 287 | use super::*; |
| 288 | |
| 289 | #[test] |
| 290 | fn test_decode() { |
| 291 | let input = vec!["69", "73", "73", "75", "65"]; |
| 292 | assert_eq!(decode_hex(&input).unwrap().join(""), "issue"); |
| 293 | |
| 294 | let empty_input: Vec<&str> = vec![]; |
| 295 | assert!(decode_hex(&empty_input).unwrap().is_empty()); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn test_decode_hex_invalid() { |
| 300 | let input = vec!["ZZ"]; |
| 301 | let result = decode_hex(&input); |
| 302 | assert!(result.is_err()); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn test_parse_replacement_empty() { |
| 307 | let input: Vec<&str> = vec![]; |
| 308 | assert_eq!(parse_replacement(&input).unwrap(), ""); |
| 309 | |
| 310 | let multiple_parts_input = vec!["03", "73", "69", "70", "04", "74", "65", "73", "74", "00"]; |
| 311 | assert_eq!( |
| 312 | parse_replacement(&multiple_parts_input).unwrap(), |
| 313 | "sip.test" |
| 314 | ); |
| 315 | } |
| 316 | |
| 317 | #[test] |
| 318 | fn test_parse_caa_record_issue() { |
| 319 | let dns_util = DnsUtil {}; |
| 320 | let record = dns_util |
| 321 | .parse_caa_record("\\# 15 00 05 69 73 73 75 65 70 6b 69 2e 67 6f 6f 67".to_owned()) |
| 322 | .unwrap(); |
| 323 | |
| 324 | assert_eq!(record.critical, 0); |
| 325 | assert_eq!(record.field, "issue"); |
| 326 | assert_eq!(record.value, "pki.goog"); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn test_parse_caa_record_issuewild() { |
| 331 | let dns_util = DnsUtil {}; |
| 332 | let record = dns_util |
| 333 | .parse_caa_record( |
| 334 | "\\# 21 00 09 69 73 73 75 65 77 69 6c 64 6c 65 74 73 65 6e 63 72 79 70 74" |
| 335 | .to_owned(), |
| 336 | ) |
| 337 | .unwrap(); |
| 338 | |
| 339 | assert_eq!(record.critical, 0); |
| 340 | assert_eq!(record.field, "issuewild"); |
| 341 | assert_eq!(record.value, "letsencrypt"); |
| 342 | } |
| 343 | |
| 344 | #[test] |
| 345 | fn test_parse_caa_record_invalid_field() { |
| 346 | let dns_util = DnsUtil {}; |
| 347 | let result = dns_util.parse_caa_record( |
| 348 | "\\# 15 00 05 69 6e 76 61 6c 69 64 70 6b 69 2e 67 6f 6f 67".to_owned(), |
| 349 | ); |
| 350 | |
| 351 | assert!(result.is_err()); |
| 352 | } |
| 353 | |
| 354 | #[test] |
| 355 | fn test_parse_naptr_record() { |
| 356 | let dns_util = DnsUtil {}; |
| 357 | let record = dns_util |
| 358 | .parse_naptr_record("\\# 37 15 b3 08 ae 01 73 0a 6d 79 2d 73 65 72 76 69 63 65 06 72 65 67 65 78 70 0b 72 65 70 6c 61 63 65 6d 65 6e 74 00".to_owned()) |
| 359 | .unwrap(); |
| 360 | |
| 361 | assert_eq!(record.flags, "s"); |
| 362 | assert_eq!(record.service, "my-service"); |
| 363 | assert_eq!(record.regexp, "regexp"); |
| 364 | assert_eq!(record.replacement, "replacement"); |
| 365 | assert_eq!(record.order, 5555); |
| 366 | assert_eq!(record.preference, 2222); |
| 367 | } |
| 368 | |
| 369 | // ========================================================================= |
| 370 | // Malformed input tests — these previously caused panics (index out of bounds) |
| 371 | // which would abort the process via CXX. They must return Err, not panic. |
| 372 | // ========================================================================= |
| 373 | |
| 374 | #[test] |
| 375 | fn test_parse_caa_record_empty_string() { |
| 376 | let dns_util = DnsUtil {}; |
| 377 | assert!(dns_util.parse_caa_record(String::new()).is_err()); |
| 378 | } |
| 379 | |
| 380 | #[test] |
| 381 | fn test_parse_caa_record_single_token() { |
| 382 | let dns_util = DnsUtil {}; |
| 383 | assert!(dns_util.parse_caa_record("\\#".to_owned()).is_err()); |
| 384 | } |
| 385 | |
| 386 | #[test] |
| 387 | fn test_parse_caa_record_two_tokens() { |
| 388 | let dns_util = DnsUtil {}; |
| 389 | assert!(dns_util.parse_caa_record("\\# 15".to_owned()).is_err()); |
| 390 | } |
| 391 | |
| 392 | #[test] |
| 393 | fn test_parse_caa_record_data_too_short_for_prefix() { |
| 394 | let dns_util = DnsUtil {}; |
| 395 | // critical=00, prefix_length=FF (255) but no data follows |
| 396 | assert!( |
| 397 | dns_util |
| 398 | .parse_caa_record("\\# 02 00 FF".to_owned()) |
| 399 | .is_err() |
| 400 | ); |
| 401 | } |
| 402 | |
| 403 | #[test] |
| 404 | fn test_parse_naptr_record_empty_string() { |
| 405 | let dns_util = DnsUtil {}; |
| 406 | assert!(dns_util.parse_naptr_record(String::new()).is_err()); |
| 407 | } |
| 408 | |
| 409 | #[test] |
| 410 | fn test_parse_naptr_record_single_token() { |
| 411 | let dns_util = DnsUtil {}; |
| 412 | assert!(dns_util.parse_naptr_record("\\#".to_owned()).is_err()); |
| 413 | } |
| 414 | |
| 415 | #[test] |
| 416 | fn test_parse_naptr_record_too_few_fields() { |
| 417 | let dns_util = DnsUtil {}; |
| 418 | assert!( |
| 419 | dns_util |
| 420 | .parse_naptr_record("\\# 37 15 b3".to_owned()) |
| 421 | .is_err() |
| 422 | ); |
| 423 | } |
| 424 | |
| 425 | #[test] |
| 426 | fn test_parse_replacement_length_exceeds_input() { |
| 427 | // First element says frame is FF (255) bytes but only 2 bytes follow |
| 428 | let input = vec!["FF", "73", "69"]; |
| 429 | assert!(parse_replacement(&input).is_err()); |
| 430 | } |
| 431 | |
| 432 | #[test] |
| 433 | fn test_parse_naptr_record_truncated_at_flags() { |
| 434 | let dns_util = DnsUtil {}; |
| 435 | // Has order+preference+flag_length but no flag data |
| 436 | assert!( |
| 437 | dns_util |
| 438 | .parse_naptr_record("\\# 06 15 b3 08 ae 05".to_owned()) |
| 439 | .is_err() |
| 440 | ); |
| 441 | } |
| 442 | } |