-
Notifications
You must be signed in to change notification settings - Fork 98
Implement support for URI constraint matching #515
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<untrusted::Input<'_>> { | ||
| 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"[..])); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a similar test where a constraint of (Justified by "When the constraint does not begin with a period, it specifies a host." in https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.10 ) |
||
| // 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's also: