Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 14 additions & 10 deletions src/subject_name/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<'_>>,
Expand Down Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions src/subject_name/uri.rs
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<'_>> {

@ctz ctz Jul 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also:

  • fuzz this? (or, better)
  • have a property test that this produces the same result as a more fully-featured URL parsing crate?

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"[..]));
}
}
146 changes: 110 additions & 36 deletions tests/tls_server_certs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a similar test where a constraint of allowed.example.com matches precisely https://allowed.example.com but not https://host.allowed.example.com?

(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
Expand Down