Skip to content

Commit 415f8ea

Browse files
committed
A tested dNSName, rfc822Name host or uniformResourceIdentifier host carrying an empty label is now refused wherever a constraint of that type is in force, rather than canonicalised into a name it is not, the single RFC 1034 root-label dot being the only empty label a name may legally carry, relates to github PR #2436.
1 parent 3533c3c commit 415f8ea

4 files changed

Lines changed: 116 additions & 7 deletions

File tree

CONTRIBUTORS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ We also wish to acknowledge financial and collaborative support from [CISCO](htt
532532
- subbudvk \<https://github.com/subbudvk\> - initial author on S2K parser hardening work for OpenPGP API.
533533
- mkarasik \<https://github.com/mkarasik\> - initial work on EST server-side key generation (RFC 7030 4.4).
534534
- Bernd Prünster (A-SIT Plus) \<bernd.pruenster&#064;a-sit.at\> - reported lenient ASN.1 UTCTime/GeneralizedTime parsing accepting structurally malformed content, with fuzzing-derived test cases.
535-
- Naveed Khan \<https://github.com/rootvector2\> - constant time comparison of membership and confirmation tags in the MLS API. Reported unbounded array allocation in the BKS/UBER keystore load path (OutOfMemoryError DoS from a crafted keystore) and introduction of a capped decompression limit in PGPCompressedData.getDataStream(), both with POC. Original submission creating S/MIME backing temp files with owner-only permissions (PR #2326). Rejecting empty sequences in the X.509 extension parsers whose RFC 5280 syntax is SEQUENCE SIZE (1..MAX) - CRLDistPoint, CertificatePolicies, ExtendedKeyUsage, PolicyMappings, and SubjectDirectoryAttributes (PR #2331). Hardening asn1.cms.SignerInfo decode to reject a non-INTEGER version or non-tagged unsignedAttrs via getInstance rather than leaking a ClassCastException (PR #2342). Fixing an off-by-4 header-length guard in the OpenPGP NotationData signature subpacket parser (PR #2346). Rejecting CR/LF in the S/MIME streaming writer header names and values (SMIMEEnvelopedWriter/SMIMESignedWriter withHeader) to prevent MIME header injection (PR #2348). Adding minimum-length guards to the SM2 decrypt and GOST28147/DSTU7624/DESede/RC2 key-wrap unwrap paths (PR #2359). Guarding the ECDH session-key length in the JCE OpenPGP decryptor (PR #2383). Fixing the identity fast-path type guards in the OER getInstance factories and the transposed isInstance arguments in OEROptional.getObject (PR #2373). Requiring the signature handed to AIMerSigner.verifySignature to be exactly the parameter set's signature size (PR #2401). Constant time comparison of the SRP-6a evidence messages (PR #2406). Surfacing a malformed TSTInfo in a time-stamp token as TSPException rather than an unchecked exception out of the TimeStampResponse constructors (PR #2415). Rejecting a PKCS#12 PBMAC1 KDF keyLength below the RFC 9579 sec. 9 floor of 20 octets (PR #2431).
535+
- Naveed Khan \<https://github.com/rootvector2\> - constant time comparison of membership and confirmation tags in the MLS API. Reported unbounded array allocation in the BKS/UBER keystore load path (OutOfMemoryError DoS from a crafted keystore) and introduction of a capped decompression limit in PGPCompressedData.getDataStream(), both with POC. Original submission creating S/MIME backing temp files with owner-only permissions (PR #2326). Rejecting empty sequences in the X.509 extension parsers whose RFC 5280 syntax is SEQUENCE SIZE (1..MAX) - CRLDistPoint, CertificatePolicies, ExtendedKeyUsage, PolicyMappings, and SubjectDirectoryAttributes (PR #2331). Hardening asn1.cms.SignerInfo decode to reject a non-INTEGER version or non-tagged unsignedAttrs via getInstance rather than leaking a ClassCastException (PR #2342). Fixing an off-by-4 header-length guard in the OpenPGP NotationData signature subpacket parser (PR #2346). Rejecting CR/LF in the S/MIME streaming writer header names and values (SMIMEEnvelopedWriter/SMIMESignedWriter withHeader) to prevent MIME header injection (PR #2348). Adding minimum-length guards to the SM2 decrypt and GOST28147/DSTU7624/DESede/RC2 key-wrap unwrap paths (PR #2359). Guarding the ECDH session-key length in the JCE OpenPGP decryptor (PR #2383). Fixing the identity fast-path type guards in the OER getInstance factories and the transposed isInstance arguments in OEROptional.getObject (PR #2373). Requiring the signature handed to AIMerSigner.verifySignature to be exactly the parameter set's signature size (PR #2401). Constant time comparison of the SRP-6a evidence messages (PR #2406). Surfacing a malformed TSTInfo in a time-stamp token as TSPException rather than an unchecked exception out of the TimeStampResponse constructors (PR #2415). Rejecting a PKCS#12 PBMAC1 KDF keyLength below the RFC 9579 sec. 9 floor of 20 octets (PR #2431). Initial implementation of the empty-label guard on name-constraint host matching, having found that the canonicalisation removed only one RFC 1034 root-label dot, so a dNSName, rfc822Name host or URI host carrying more than one matched no constraint at all (PR #2436).
536536
- suraj0208 \<https://github.com/suraj0208\> - initial work on auto-detecting private key reader (JcaPrivateKeyReader).
537537
- liamgilligan \<https://github.com/liamgilligan\> - noticing the BIP-340 step numbering in the BIP340Signer signing comments was incorrect (PR #2340).
538538
- digi-scrypt \<https://github.com/digi-scrypt\> - disabling DTD and external-entity resolution in KMIPInputStream to close an XXE (local file disclosure / SSRF) exposure in KMIP XML parsing (PR #2315).

core/src/main/java/org/bouncycastle/asn1/x509/PKIXNameConstraintValidator.java

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,7 @@ private static void checkPermittedEmail(Set permitted, String email)
859859
}
860860

861861
checkEmailNotAmbiguous(email);
862+
checkNoEmptyLabel(emailHost(email), "Subject email address host");
862863

863864
if (!isEmailConstrained(permitted, email))
864865
{
@@ -875,6 +876,7 @@ private static void checkExcludedEmail(Set excluded, String email)
875876
}
876877

877878
checkEmailNotAmbiguous(email);
879+
checkNoEmptyLabel(emailHost(email), "Subject email address host");
878880

879881
if (isEmailConstrained(excluded, email))
880882
{
@@ -899,6 +901,18 @@ private static void checkEmailNotAmbiguous(String email)
899901
}
900902
}
901903

904+
/**
905+
* The host of a tested rfc822Name, which is everything past the '@' - or the whole value when
906+
* there is none, the form a bare domain takes. Only meaningful once
907+
* {@link #checkEmailNotAmbiguous} has established there is at most one '@', so that the local
908+
* part cannot be mistaken for the host; the local part is deliberately left out, since a quoted
909+
* one may legally carry the doubled dot {@link #checkNoEmptyLabel} refuses in a host.
910+
*/
911+
private static String emailHost(String email)
912+
{
913+
return email.substring(email.indexOf('@') + 1);
914+
}
915+
902916
private static void checkPermittedOtherName(Set permitted, OtherName otherName)
903917
throws NameConstraintValidatorException
904918
{
@@ -1191,6 +1205,13 @@ private static boolean withinDomain(String testDomain, String domain)
11911205
private static void checkExcludedDNS(Set excluded, String dns)
11921206
throws NameConstraintValidatorException
11931207
{
1208+
if (excluded.isEmpty())
1209+
{
1210+
return;
1211+
}
1212+
1213+
checkNoEmptyLabel(dns, "DNS name");
1214+
11941215
if (isDNSConstrained(excluded, dns))
11951216
{
11961217
throw new NameConstraintValidatorException("DNS is from an excluded subtree.");
@@ -1200,9 +1221,14 @@ private static void checkExcludedDNS(Set excluded, String dns)
12001221
private static void checkPermittedDNS(Set permitted, String dns)
12011222
throws NameConstraintValidatorException
12021223
{
1203-
if (permitted != null
1204-
&& !(dns.length() == 0 && permitted.size() == 0)
1205-
&& !isDNSConstrained(permitted, dns))
1224+
if (permitted == null || (dns.length() == 0 && permitted.size() == 0))
1225+
{
1226+
return;
1227+
}
1228+
1229+
checkNoEmptyLabel(dns, "DNS name");
1230+
1231+
if (!isDNSConstrained(permitted, dns))
12061232
{
12071233
throw new NameConstraintValidatorException("DNS is not from a permitted subtree.");
12081234
}
@@ -1249,6 +1275,29 @@ private static String stripTrailingDot(String s)
12491275
return s;
12501276
}
12511277

1278+
/**
1279+
* A tested host must carry no empty label. RFC 1034 sec. 3.5 admits none but the root, so the
1280+
* single trailing dot of a fully-qualified name is legal - and {@link #stripTrailingDot} removes
1281+
* it - while a leading dot, a doubled dot or a second trailing dot is not a name RFC 5280
1282+
* sec. 4.2.1.6 admits. Such a value is refused rather than canonicalised: deciding that
1283+
* "example.com.." names example.com would be this validator's guess, and a consumer resolving or
1284+
* comparing the name does not read it that way, so a name it cannot match is failed closed. This
1285+
* applies to tested names only - a constraint may legitimately begin with a dot, which is how
1286+
* this implementation spells "subdomains only".
1287+
*
1288+
* @param host the host part of the tested name.
1289+
* @param nameType how to describe the name in the exception message.
1290+
* @throws NameConstraintValidatorException if the host carries an empty label.
1291+
*/
1292+
private static void checkNoEmptyLabel(String host, String nameType)
1293+
throws NameConstraintValidatorException
1294+
{
1295+
if (host.indexOf("..") >= 0 || (host.length() > 1 && host.charAt(0) == '.'))
1296+
{
1297+
throw new NameConstraintValidatorException(nameType + " has an empty label.");
1298+
}
1299+
}
1300+
12521301
/**
12531302
* The common part of <code>email1</code> and <code>email2</code> is
12541303
* added to the union <code>union</code>. If <code>email1</code> and
@@ -1709,6 +1758,13 @@ else if (email2.startsWith("."))
17091758
private static void checkExcludedURI(Set excluded, String uri)
17101759
throws NameConstraintValidatorException
17111760
{
1761+
if (excluded.isEmpty())
1762+
{
1763+
return;
1764+
}
1765+
1766+
checkNoEmptyLabel(extractHostFromURL(uri), "URI host");
1767+
17121768
if (isURIConstrained(excluded, uri))
17131769
{
17141770
throw new NameConstraintValidatorException("URI is from an excluded subtree.");
@@ -1859,9 +1915,14 @@ else if (email2.startsWith("."))
18591915
private static void checkPermittedURI(Set permitted, String uri)
18601916
throws NameConstraintValidatorException
18611917
{
1862-
if (permitted != null
1863-
&& !(uri.length() == 0 && permitted.size() == 0)
1864-
&& !isURIConstrained(permitted, uri))
1918+
if (permitted == null || (uri.length() == 0 && permitted.size() == 0))
1919+
{
1920+
return;
1921+
}
1922+
1923+
checkNoEmptyLabel(extractHostFromURL(uri), "URI host");
1924+
1925+
if (!isURIConstrained(permitted, uri))
18651926
{
18661927
throw new NameConstraintValidatorException("URI is not from a permitted subtree.");
18671928
}

docs/releasenotes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Date: 2026, TBD
2121
- A DTLS handshake deadlocked when a handshake message ahead of the peer's ChangeCipherSpec (a client's CertificateVerify, say) was lost while the ChangeCipherSpec and Finished behind it arrived: the record layer moved its read epoch on at the ChangeCipherSpec and then discarded every retransmission of the lost message as belonging to the old epoch, whose records are only accepted once the handshake has completed. Each side then waited on the other until a handshake timeout, if one was configured, ended it. Every client-authenticated handshake, and every handshake in which the server issues a NewSessionTicket, was exposed. Until the handshake completes, handshake records from the current epoch are now still accepted after the read epoch has moved on, and each message is checked against the epoch of the record that carried it. The DTLS loopback tests now run their handshakes at 10% datagram loss in each direction, with a client-authenticated handshake at 25%.
2222
- The lightweight SubjectPublicKeyInfoFactory and PrivateKeyInfoFactory encoded a GOST R 34.10-2012 key on one of the legacy CryptoPro curves under id-GostR3410-2001, although RFC 9215 sec. 4.2 permits those curves for 2012 keys. The digestParamSet now decides: a GOST R 34.11-94 parameter set means 2001 (RFC 4491 sec. 2.3.2), a GOST R 34.11-2012 digest or none means 2012 with 256/512 taken from the curve field size, and any other value is rejected. GOST3410PublicKeyAlgParameters treats digestParamSet as OPTIONAL on both read and write per RFC 9215, and PrivateKeyInfoFactory now passes attributes through for ECGOST3410 keys (bc-csharp github #707).
2323

24+
- The name-constraint host canonicalisation removed a single RFC 1034 root-label dot, the only empty label a name may legally carry, but nothing refused the ones that are not legal: a dNSName, rfc822Name host or uniformResourceIdentifier host such as "example.com.." kept a phantom empty label after the strip and so matched no constraint at all, escaping an excluded subtree naming the host it appears to carry. A tested name whose host carries an empty label - a second trailing dot, a doubled dot or a leading dot - is now refused outright wherever a constraint of that type is in force, rather than canonicalised into a name it is not: removing the extra dots would decide on the caller's behalf that "example.com.." names example.com, which is not how a consumer resolving or comparing the name reads it, and refusing fails closed in both directions where canonicalising would newly admit such a name under a permitted subtree. The single trailing dot is canonicalised as before, a bare "." remains the root label rather than an empty one, and the guard is scoped to the host, so the doubled dot a quoted local part may legally carry is unaffected. Constraints are untouched - one may still begin with a dot, which is how this implementation spells "subdomains only" (github PR #2436).
25+
2426
### 2.1.3 Additional Features and Functionality
2527

2628
### 2.1.4 Additional Notes

prov/src/test/java/org/bouncycastle/jce/provider/test/PKIXNameConstraintsTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ public void performTest() throws Exception
266266
testUriHostExtractionBypass();
267267
testIPv4MappedAddressBypass();
268268
testQuotedLocalPartEmailBypass();
269+
testEmptyLabelRefused();
269270

270271
testSGP22LegacySerialNumber();
271272
testSGP22NameConstraints();
@@ -297,6 +298,51 @@ private void testTrailingDotBypass() throws Exception
297298
isExcluded(uriName("competitor.example"), uriName("https://competitor.example./")));
298299
}
299300

301+
/**
302+
* Only the single root-label dot of RFC 1034 sec. 3.1 is canonicalized away. A host carrying any
303+
* other empty label - a second trailing dot, a doubled dot, a leading dot - is not a name
304+
* RFC 5280 sec. 4.2.1.6 admits, and stripping the extra dots would decide on the caller's behalf
305+
* that "example.com.." names example.com. It is refused instead, in both directions, so it can
306+
* neither escape an excluded subtree nor be admitted by a permitted one.
307+
*/
308+
private void testEmptyLabelRefused() throws Exception
309+
{
310+
// dNSName: every shape of empty label is refused, whichever domain the name appears to carry.
311+
isTrue("two trailing dots must be refused",
312+
isExcluded(dnsName("example.com"), dnsName("example.com..")));
313+
isTrue("three trailing dots must be refused",
314+
isExcluded(dnsName("example.com"), dnsName("example.com...")));
315+
isTrue("a doubled inner dot must be refused",
316+
isExcluded(dnsName("example.com"), dnsName("foo..example.com")));
317+
isTrue("a leading dot must be refused",
318+
isExcluded(dnsName("example.com"), dnsName(".example.com")));
319+
isTrue("a sibling domain with an empty label must be refused too",
320+
isExcluded(dnsName("example.com"), dnsName("notexample.com..")));
321+
322+
// the permitted direction refuses it as well, so canonicalizing has not admitted anything new.
323+
isTrue("an empty label must not be permitted",
324+
!isPermitted(dnsName("example.com"), dnsName("example.com..")));
325+
isTrue("a well-formed name is still permitted",
326+
isPermitted(dnsName("example.com"), dnsName("example.com.")));
327+
328+
// rfc822Name and URI share the guard, applied to the host.
329+
isTrue("two trailing dots on a mail host must be refused",
330+
isExcluded(emailName("bank.com"), emailName("ceo@bank.com..")));
331+
isTrue("two trailing dots on a URI host must be refused",
332+
isExcluded(uriName("competitor.example"), uriName("https://competitor.example../")));
333+
334+
// the guard is scoped to the host: a quoted local part may legally carry a doubled dot.
335+
isTrue("a doubled dot in a quoted local part must not be refused, and the host still matches",
336+
isExcluded(emailName("bank.com"), emailName("\"a..b\"@bank.com")));
337+
338+
// a bare "." is the root label, not an empty one, and is left alone.
339+
isTrue("a bare root label must not be refused", !isExcluded(dnsName("example.com"), dnsName(".")));
340+
341+
// nothing is refused where no constraint of that type is in force.
342+
isTrue("an empty label is immaterial with no dNSName constraint",
343+
!isExcluded(emailName("bank.com"), dnsName("example.com..")));
344+
}
345+
300346
/**
301347
* A tested rfc822Name with more than one '@' is ambiguous - a quoted local part may legally contain
302348
* '@' (RFC 5321 sec. 4.1.2), so the domain is after the LAST '@', not the first. Rather than split at

0 commit comments

Comments
 (0)