diff --git a/Cargo.toml b/Cargo.toml index 4f740fdb..39487608 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", 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..8d554dfd --- /dev/null +++ b/src/subject_name/uri.rs @@ -0,0 +1,140 @@ +// 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 super::*; + + fn host(uri: &[u8]) -> Option<&[u8]> { + host_of(untrusted::Input::from(uri)).map(|h| h.as_slice_less_safe()) + } + + #[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"[..])); + } +} diff --git a/tests/tls_server_certs.rs b/tests/tls_server_certs.rs index 3c04cca8..dc195358 100644 --- a/tests/tls_server_certs.rs +++ b/tests/tls_server_certs.rs @@ -610,61 +610,135 @@ 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"); - - let ee = generate_cert( - vec![SanType::URI("https://evil.example.com".try_into().unwrap())], - &issuer, +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_cert(ee.der(), issuer.der(), &[], &[], &[]), + 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), ); } -/// 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_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_uri_constraint( + b".allowed.example.com", + PERMITTED, + "https://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