diff --git a/Cargo.lock b/Cargo.lock index 4be00116..fa0080b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dfdb4953a096c551ce9ace855a604d702e6e62d77fac690575ae347571717f5" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -106,6 +121,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "bumpalo" version = "3.20.3" @@ -217,18 +238,49 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -323,6 +375,109 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "itoa" version = "1.0.18" @@ -374,6 +529,18 @@ dependencies = [ "typify", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.32" @@ -451,18 +618,42 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -472,6 +663,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec 0.8.0", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -487,6 +703,44 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "rcgen" version = "0.14.8" @@ -500,6 +754,12 @@ dependencies = [ "yasna", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "regress" version = "0.11.1" @@ -533,6 +793,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustls" version = "0.24.0-dev.1" @@ -599,6 +872,7 @@ dependencies = [ "chrono", "limbo-harness-support", "once_cell", + "proptest", "rcgen", "rustls-aws-lc-rs", "rustls-pki-types", @@ -607,6 +881,7 @@ dependencies = [ "serde", "serde_json", "untrusted 0.9.0", + "url", "x509-parser", ] @@ -616,6 +891,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "schemars" version = "0.8.22" @@ -728,6 +1015,18 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -767,6 +1066,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -818,6 +1130,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typify" version = "0.7.0" @@ -865,6 +1187,12 @@ dependencies = [ "typify-impl", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -883,6 +1211,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1081,6 +1436,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x509-parser" version = "0.18.1" @@ -1105,16 +1466,113 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "time", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index f18525d6..169e4abf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ include = [ "src/subject_name/mod.rs", "src/subject_name/name.rs", "src/subject_name/verify.rs", + "src/subject_name/uri.rs", "src/name/verify.rs", "src/name/name.rs", "src/signed_data.rs", @@ -85,12 +86,14 @@ bencher = "0.1.5" chrono = "0.4" limbo-harness-support = { git = "https://github.com/C2SP/x509-limbo", rev = "1252c300df48507fb27e709fb287c01a9caa1e0b" } once_cell = "1.17.2" +proptest = "1" rcgen = { version = "0.14.7", default-features = false, features = ["aws_lc_rs"] } rustls-aws-lc-rs = { version = "0.1.0-dev.1" } rustls-post-quantum = { version = "0.3.0-dev.2" } rustls-ring = { version = "0.1.0-dev.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +url = "2" x509-parser = "0.18" [profile.bench] diff --git a/src/subject_name/mod.rs b/src/subject_name/mod.rs index 4c50c18f..b7c56afe 100644 --- a/src/subject_name/mod.rs +++ b/src/subject_name/mod.rs @@ -28,6 +28,8 @@ pub(crate) use dns_name::{WildcardDnsNameRef, verify_dns_names}; mod ip_address; pub(crate) use ip_address::verify_ip_address_names; +mod uri; + // https://www.rfc-editor.org/info/rfc5280/#section-4.2.1.10 pub(crate) fn check_name_constraints( constraints: Option<&mut untrusted::Reader<'_>>, @@ -162,17 +164,19 @@ fn check_presented_id_conforms_to_constraints( } (GeneralName::IpAddress(_), _) => continue, - // We currently don't support URI constraints -- fail closed for now. - // - // Rejection is achieved by not matching any PermittedSubtrees, and matching all - // ExcludedSubtrees. + // For URI constraints, use the DNS name matching for the host part of the URI + // https://www.rfc-editor.org/info/rfc5280/#section-4.2.1.10 ( - GeneralName::UniformResourceIdentifier(_), - GeneralName::UniformResourceIdentifier(_), - ) => Ok(match subtrees { - Subtrees::Permitted => false, - Subtrees::Excluded => true, - }), + GeneralName::UniformResourceIdentifier(name), + GeneralName::UniformResourceIdentifier(base), + ) => match uri::host_of(name) { + Some(host) => dns_name::presented_id_matches_reference_id( + host, + IdRole::NameConstraint(subtrees), + base, + ), + None => Err(Error::NameConstraintViolation), + }, (GeneralName::UniformResourceIdentifier(_), _) => continue, // RFC 5280 says "If a name constraints extension that is marked as diff --git a/src/subject_name/uri.rs b/src/subject_name/uri.rs new file mode 100644 index 00000000..8a91e592 --- /dev/null +++ b/src/subject_name/uri.rs @@ -0,0 +1,234 @@ +// Copyright 2025 webpki Authors. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +/// Returns the fully qualified domain name authority component of `uri`. +/// +/// RFC 5280 § 4.2.1.10 defines `uniformResourceIdentifier` name constraints in terms of the host +/// part of the URI, matched using the same rules as `dNSName` constraints. +/// +/// Parsing follows the generic RFC 3986 authority syntax and is intentionally minimal: anything +/// ambiguous fails closed (returns `None`). +pub(super) fn host_of(uri: untrusted::Input<'_>) -> Option> { + let uri = uri.as_slice_less_safe(); + + // RFC 3986: URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ]. + let (_, rest) = split_once(uri, |&b| b == b':')?; + + // An authority component is only present after a "//" following the scheme delimiter. + let rest = rest.strip_prefix(b"//")?; + + // authority ends at the first "/", "?", or "#". + let authority = match split_once(rest, |&b| matches!(b, b'/' | b'?' | b'#')) { + Some((authority, _)) => authority, + None => rest, + }; + + // Drop any userinfo ("user:pass@"); it ends at the last "@". + let host_port = match split_once(authority, |&b| b == b'@') { + Some((_, host_port)) => host_port, + None => authority, + }; + + // An IPv6 (or IP-future) literal is bracketed; per RFC 5280 IP hosts are not allowed. + if host_port.first() == Some(&b'[') { + return None; + } + + // Strip a trailing ":port". Since we've excluded bracketed literals above, the only + // remaining colon is the port separator. + let host = match split_once(host_port, |&b| b == b':') { + Some((host, _)) => host, + None => host_port, + }; + if host.is_empty() { + return None; + } + + let mut octets = 0; + for label in host.split(|&b| b == b'.') { + if label.is_empty() || label.len() > 3 || !label.iter().all(u8::is_ascii_digit) { + octets = 0; // Not an IPv4 literal + break; + } + octets += 1; + } + + match octets { + 4 => None, // IPv4 literal + _ => Some(untrusted::Input::from(host)), + } +} + +/// `slice::split_once()` is not stable yet +fn split_once(bytes: &[u8], pred: impl FnMut(&u8) -> bool) -> Option<(&[u8], &[u8])> { + let index = bytes.iter().position(pred)?; + Some((&bytes[..index], &bytes[index + 1..])) +} + +#[cfg(test)] +mod tests { + use std::string::String; + use std::{format, str}; + + use proptest::prelude::*; + use url::{Host, Url}; + + use super::*; + + #[test] + fn simple() { + assert_eq!(host(b"https://example.com"), Some(&b"example.com"[..])); + assert_eq!( + host(b"https://host.example.com/path"), + Some(&b"host.example.com"[..]) + ); + } + + #[test] + fn port_userinfo_path_query_fragment() { + assert_eq!(host(b"https://example.com:8443"), Some(&b"example.com"[..])); + assert_eq!( + host(b"https://user@example.com/p"), + Some(&b"example.com"[..]) + ); + assert_eq!( + host(b"https://user:pass@example.com:8443/p?q#f"), + Some(&b"example.com"[..]) + ); + assert_eq!(host(b"https://example.com?q"), Some(&b"example.com"[..])); + assert_eq!(host(b"https://example.com#f"), Some(&b"example.com"[..])); + } + + #[test] + fn no_authority() { + assert_eq!(host(b"urn:example:animal"), None); + assert_eq!(host(b"mailto:user@example.com"), None); + assert_eq!(host(b"example.com"), None); + } + + #[test] + fn empty_host() { + assert_eq!(host(b"file:///path"), None); + assert_eq!(host(b"https://:8443/p"), None); + assert_eq!(host(b"https://user@/p"), None); + } + + #[test] + fn ip_literal_host() { + assert_eq!(host(b"https://127.0.0.1/p"), None); + assert_eq!(host(b"https://127.0.0.1:8443"), None); + assert_eq!(host(b"https://[2001:db8::1]/p"), None); + assert_eq!(host(b"https://[2001:db8::1]:8443"), None); + } + + #[test] + fn not_ip_literal() { + // Trailing/short label counts that aren't four octets are hostnames. + assert_eq!(host(b"https://1.2.3"), Some(&b"1.2.3"[..])); + assert_eq!(host(b"https://1.2.3.4.5"), Some(&b"1.2.3.4.5"[..])); + assert_eq!(host(b"https://1234.2.3.4"), Some(&b"1234.2.3.4"[..])); + } + + fn host(uri: &[u8]) -> Option<&[u8]> { + host_of(untrusted::Input::from(uri)).map(|h| h.as_slice_less_safe()) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(4096))] + + // Cross-check against the `url` crate on URIs where RFC 3986 and the WHATWG URL + // standard agree. The generator avoids inputs the two standards parse differently: + // hosts with a numeric or "xn--" last label, percent-encoding, IPv4 literals with + // non-special schemes, out-of-range ports, and raw "@" or "\" in the authority. + #[test] + fn consistent_with_url_crate(uri in uri_strategy()) { + let expected = match Url::parse(&uri) { + Ok(url) => match url.host() { + Some(Host::Domain(host)) if !host.is_empty() => { + Some(host.to_ascii_lowercase()) + } + Some(Host::Domain(_)) | Some(Host::Ipv4(_)) | Some(Host::Ipv6(_)) | None => { + None + } + }, + Err(_) => None, + }; + + let actual = host(uri.as_bytes()) + .map(|host| str::from_utf8(host).unwrap().to_ascii_lowercase()); + prop_assert_eq!(actual, expected, "uri = {:?}", uri); + } + } + + fn uri_strategy() -> impl Strategy { + let special = prop::sample::select(&["http", "https", "ws", "wss", "ftp"][..]); + let non_special = prop::sample::select(&["coap", "git", "spiffe", "ldap"][..]); + let scheme_and_host = prop_oneof![ + (special, prop_oneof![domain(), ipv4_quad(), ipv6_literal()]), + (non_special, prop_oneof![domain(), ipv6_literal()]), + ]; + + (scheme_and_host, userinfo(), port(), tail()).prop_map( + |((scheme, host), userinfo, port, tail)| { + format!("{scheme}://{userinfo}{host}{port}{tail}") + }, + ) + } + + fn domain() -> impl Strategy { + let label = proptest::string::string_regex("[A-Za-z0-9_-]{1,8}").unwrap(); + let last = proptest::string::string_regex("[A-Za-z][A-Za-z0-9_-]{0,7}").unwrap(); + (prop::collection::vec(label, 0..=3), last) + .prop_map(|(mut labels, last)| { + labels.push(last); + labels.join(".") + }) + .prop_filter("xn-- labels trigger punycode decoding", |host| { + !host + .to_ascii_lowercase() + .split('.') + .any(|label| label.starts_with("xn--")) + }) + } + + fn ipv4_quad() -> impl Strategy { + let octet = proptest::string::string_regex("[0-9]{1,3}").unwrap(); + prop::collection::vec(octet, 4).prop_map(|octets| octets.join(".")) + } + + fn ipv6_literal() -> impl Strategy { + proptest::string::string_regex("\\[(2001:db8::1|::1|::|fe80::a:b)\\]").unwrap() + } + + fn userinfo() -> impl Strategy { + let userinfo = proptest::string::string_regex("[a-z0-9:]{0,8}@").unwrap(); + prop_oneof![Just(String::new()), userinfo] + } + + fn port() -> impl Strategy { + prop_oneof![ + Just(String::new()), + Just(String::from(":")), + (0..=0xffffu32).prop_map(|port| format!(":{port}")), + ] + } + + fn tail() -> impl Strategy { + let path = proptest::string::string_regex("(/[A-Za-z0-9._~-]{0,6}){0,3}").unwrap(); + let query = proptest::string::string_regex("(\\?[a-z0-9=&._-]{0,8})?").unwrap(); + let fragment = proptest::string::string_regex("(#[a-z0-9._-]{0,8})?").unwrap(); + (path, query, fragment) + .prop_map(|(path, query, fragment)| format!("{path}{query}{fragment}")) + } +} diff --git a/tests/tls_server_certs.rs b/tests/tls_server_certs.rs index 89aba249..ec7cd741 100644 --- a/tests/tls_server_certs.rs +++ b/tests/tls_server_certs.rs @@ -612,61 +612,156 @@ fn ip46_mixed_address_san_allowed() { ); } -/// Since we don't have real constraint matching implemented for URI names, fail closed. +// RFC 5280 § 4.2.1.10: URI constraints apply to the host part of the name, +// matched using the same rules as dNSName constraints. The constraint is a +// bare host/domain (`allowed.example.com` or `.example.com`), not a full URI. #[test] -fn uri_san_rejected_against_uri_permitted_subtree() { - let ca_key = KeyPair::generate().unwrap(); - let mut ca_params = issuer_params("issuer.example.com").unwrap(); - ca_params - .custom_extensions - .push(uri_permitted_name_constraints( - b"https://allowed.example.com", - )); - let issuer = CertifiedIssuer::self_signed(ca_params, ca_key).expect("failed to generate CA"); +fn uri_san_matches_uri_permitted_subtree() { + // As with dNSName constraints (and Go's matchDomainConstraint, which backs + // both), a bare constraint matches the exact host and any subdomain of it. + assert_eq!( + check_uri_constraint( + b"allowed.example.com", + PERMITTED, + "https://allowed.example.com" + ), + Ok(()), + ); + assert_eq!( + check_uri_constraint( + b"allowed.example.com", + PERMITTED, + "https://sub.allowed.example.com", + ), + Ok(()), + ); + // A URI that is not below the permitted subtree is rejected. + assert_eq!( + check_uri_constraint( + b"allowed.example.com", + PERMITTED, + "https://evil.example.com" + ), + Err(webpki::Error::NameConstraintViolation), + ); +} - let ee = generate_cert( - vec![SanType::URI("https://evil.example.com".try_into().unwrap())], - &issuer, +#[test] +fn uri_san_matches_leading_dot_permitted_subtree() { + // A leading dot matches subdomains, but not the bare domain itself. + assert_eq!( + check_uri_constraint( + b".allowed.example.com", + PERMITTED, + "https://host.allowed.example.com", + ), + Ok(()), ); assert_eq!( - check_cert(ee.der(), issuer.der(), &[], &[], &[]), + check_uri_constraint( + b".allowed.example.com", + PERMITTED, + "https://allowed.example.com", + ), Err(webpki::Error::NameConstraintViolation), ); } -/// Since we don't have real constraint matching implemented for URI names, fail closed. #[test] -fn uri_san_rejected_against_uri_excluded_subtree() { - let ca_key = KeyPair::generate().unwrap(); - let mut ca_params = issuer_params("issuer.example.com").unwrap(); - ca_params - .custom_extensions - .push(uri_excluded_name_constraints(b"https://evil.example.com")); - let issuer = CertifiedIssuer::self_signed(ca_params, ca_key).expect("failed to generate CA"); +fn uri_san_matches_nested_hostname() { + // A leading dot matches subdomains, but not the bare domain itself. + assert_eq!( + check_uri_constraint( + b"allowed.example.com", + PERMITTED, + "https://allowed.example.com", + ), + Ok(()), + ); + assert_eq!( + check_uri_constraint( + b"allowed.example.com", + EXCLUDED, + "https://host.allowed.example.com", + ), + Err(webpki::Error::NameConstraintViolation), + ); +} - let ee = generate_cert( - vec![SanType::URI("https://evil.example.com".try_into().unwrap())], - &issuer, +#[test] +fn uri_san_ignores_userinfo_and_port() { + // The host is extracted from the authority, ignoring userinfo and port. + assert_eq!( + check_uri_constraint( + b".allowed.example.com", + PERMITTED, + "https://user@host.allowed.example.com:8443/path?q#frag", + ), + Ok(()), ); +} + +#[test] +fn uri_san_matches_uri_excluded_subtree() { + // A URI whose host falls in the excluded subtree is rejected. assert_eq!( - check_cert(ee.der(), issuer.der(), &[], &[], &[]), + check_uri_constraint(b"evil.example.com", EXCLUDED, "https://evil.example.com"), Err(webpki::Error::NameConstraintViolation), ); + // A URI outside the excluded subtree is allowed. + assert_eq!( + check_uri_constraint(b"evil.example.com", EXCLUDED, "https://good.example.com"), + Ok(()), + ); } -// Hand-encode a NameConstraints extension (OID 2.5.29.30) with a single -// permittedSubtree containing a URI GeneralName. rcgen's GeneralSubtree enum -// doesn't expose a URI variant, so we emit the DER directly. -fn uri_permitted_name_constraints(uri: &[u8]) -> CustomExtension { - uri_name_constraints(uri, 0xa0) // permittedSubtrees [0] IMPLICIT +#[test] +fn uri_san_without_fqdn_host_rejected() { + // RFC 5280 requires rejecting a URI SAN that has no authority component, or + // whose host is an IP address, when a URI constraint applies to it. + for san in [ + "urn:example:animal", // no authority component + "https://127.0.0.1/p", // IPv4 host + "https://[2001:db8::1]/p", // IPv6 host + ] { + assert_eq!( + check_uri_constraint(b"allowed.example.com", PERMITTED, san), + Err(webpki::Error::NameConstraintViolation), + "expected {san} to be rejected against a permitted subtree", + ); + assert_eq!( + check_uri_constraint(b"allowed.example.com", EXCLUDED, san), + Err(webpki::Error::NameConstraintViolation), + "expected {san} to be rejected against an excluded subtree", + ); + } } -// Hand-encode a NameConstraints extension (OID 2.5.29.30) with a single -// excludedSubtree containing a URI GeneralName. -fn uri_excluded_name_constraints(uri: &[u8]) -> CustomExtension { - uri_name_constraints(uri, 0xa1) // excludedSubtrees [1] IMPLICIT +// permittedSubtrees [0] IMPLICIT / excludedSubtrees [1] IMPLICIT +const PERMITTED: u8 = 0xa0; +const EXCLUDED: u8 = 0xa1; + +// Build an issuer carrying a single URI name constraint over `constraint`, issue +// an EE cert whose only SAN is `san_uri`, and return the path-building result. +fn check_uri_constraint( + constraint: &[u8], + subtrees_tag: u8, + san_uri: &str, +) -> Result<(), webpki::Error> { + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = issuer_params("issuer.example.com").unwrap(); + ca_params + .custom_extensions + .push(uri_name_constraints(constraint, subtrees_tag)); + let issuer = CertifiedIssuer::self_signed(ca_params, ca_key).expect("failed to generate CA"); + + let ee = generate_cert(vec![SanType::URI(san_uri.try_into().unwrap())], &issuer); + check_cert(ee.der(), issuer.der(), &[], &[], &[]) } +// Hand-encode a NameConstraints extension (OID 2.5.29.30) with a single +// permitted or excluded subtree containing a URI GeneralName. rcgen's +// GeneralSubtree enum doesn't expose a URI variant, so we emit the DER directly. fn uri_name_constraints(uri: &[u8], subtrees_tag: u8) -> CustomExtension { assert!(uri.len() < 128); // URI GeneralName: [6] IMPLICIT IA5String