Skip to content

Reject present-but-empty name constraint subtrees - #15560

Open
avalyset wants to merge 5 commits into
pyca:mainfrom
avalyset:fix/rust-empty-permitted-subtrees
Open

Reject present-but-empty name constraint subtrees#15560
avalyset wants to merge 5 commits into
pyca:mainfrom
avalyset:fix/rust-empty-permitted-subtrees

Conversation

@avalyset

Copy link
Copy Markdown

Reported privately as GHSA-fw9w-89pp-fpmg. The advisory is not public, so there is nothing to link. The response there was that this is a regular bug rather than a security issue, with a request to open a public PR instead; this is that PR. We had offered no severity assessment either way.

The rule is already enforced in the Python layer

src/cryptography/x509/extensions.py:1344:

if not permitted_subtrees:
    raise ValueError(
        "permitted_subtrees must be a non-empty list or None"
    )

That check came from #6982, "Possible bug: empty sequence in NameConstraints" (opened and closed 2022-03-19), where the conclusion was explicit:

Yes, this is a bug, we should be rejecting non-none values that are less than 0 [sic] because:

GeneralSubtrees ::= SEQUENCE SIZE (1..MAX) OF GeneralSubtree

The Rust path validator did not enforce it

policy/extension.rs rejected only when both subtree fields were empty, so an empty permittedSubtrees combined with a non-empty excludedSubtrees passed that check. In lib.rs:258-274 the permit flag then keeps its default:

let mut permit = true;
if let Some(permitted_subtrees) = &constraints.permitted_subtrees {
    for p in permitted_subtrees.clone() {
        ...
    }
}

With an empty sequence the loop body never runs, so permit stays true and every SAN is accepted by the permitted side.

Basis

RFC 5280 6.1.4 (g)(1) sets permitted_subtrees to the intersection of its previous value and the value in the extension; the intersection with an empty set is empty. RFC 5280 6.1.3 (b) requires the name to lie within permitted_subtrees, so with an empty one no name qualifies and every certificate below that CA should be rejected. It was accepting all of them instead.

Reaching this requires a signed CA certificate carrying such an extension, and RFC 5280 4.2.1.10 says conforming CAs must not issue one.

The change

GeneralSubtrees ::= SEQUENCE SIZE (1..MAX) constrains both fields, so this rejects a present-but-empty permittedSubtrees and a present-but-empty excludedSubtrees. Only the first has any consequence; an empty excludedSubtrees excludes nothing, which is harmless. But it is the same ASN.1 requirement on the same type, and enforcing it for one field and not the other seemed arbitrary. Happy to drop the second check if you would rather keep the diff to the field that matters.

The existing both-empty check is kept. I checked whether it became redundant: it does not. It is the only one that covers a NameConstraints where neither field is present, which is_some_and by construction never matches.

Behaviour

The probe from the advisory, through the public API. The CA's NameConstraints has to be built as raw DER, because the Python API refuses to construct this shape at all, which is itself part of the point.

Before, against 50.0.1 from PyPI:

Python API rejects empty permitted_subtrees -> ValueError: permitted_subtrees must be a non-empty list or None
NameConstraints DER used: 30 13 a0 00 a1 0f 30 0d 82 0b 62 61 64 2e 65 78 61 6d 70 6c 65

  permitted=EMPTY,        excluded=[bad.example] -> ACCEPTED
  permitted=[ok.example], excluded=[bad.example] -> rejected: validation failed: candidates exhausted: no permitted name constraints matched SAN

After, with this branch:

  permitted=EMPTY,        excluded=[bad.example] -> rejected: validation failed: candidates exhausted: nameConstraints permittedSubtrees must not be empty
  permitted=[ok.example], excluded=[bad.example] -> rejected: validation failed: candidates exhausted: no permitted name constraints matched SAN

The second line is the control, and it is unchanged.

Tests

Three tests in policy::extension::tests, using raw DER because an empty sequence cannot be built through the writing API: empty permittedSubtrees with non-empty excludedSubtrees, empty excludedSubtrees with non-empty permittedSubtrees, and a control with both non-empty that must still be accepted.

Verified they fail without the fix: reverting the check and keeping the tests turns both rejection tests red while the control stays green.

Checks

  • cargo test --all green across the workspace, including the 43 tests in cryptography-x509-verification
  • cargo fmt --all -- --check clean
  • cargo clippy -p cryptography-x509-verification --all-targets: 8 warnings, the same 8 as on main, none on the changed lines
  • pytest tests/: 4490 passed, 199 skipped (wycheproof_root and x509_limbo_root not available locally)

GeneralSubtrees is SEQUENCE SIZE (1..MAX), so a subtree field that is
present must not be empty. An empty permittedSubtrees was accepted and
then had no effect, because the loop over the subtrees never runs, so
every name passed the permitted side instead of none.
@alex

alex commented Sep 1, 2026

Copy link
Copy Markdown
Member

It should be possible to write a test case for this against the Python APIs - that's preferrable to writing directly against the Rust APIs. Ideally this could just be a testcase in x509-limbo.

@avalyset

avalyset commented Sep 1, 2026

Copy link
Copy Markdown
Author

Done — the testcase is now C2SP/x509-limbo#658, as rfc5280::nc::permitted-empty-sequence-excluded-nonempty, with rfc5280::nc::permitted-nonempty-excluded-nonempty as the control. It builds the extension through the Python API using the private-attribute bypass the neighbouring intermediate_permitted_excluded_subtrees_both_* cases already use, so nothing is hand-encoded. Against 50.0.1 the control passes and the empty-permittedSubtrees chain is accepted where limbo expects it to fail.

Should the Rust tests in this PR come out once the limbo case lands, or stay as a unit test?

@alex

alex commented Sep 1, 2026

Copy link
Copy Markdown
Member

Yes, the rust tests should be removed once tha tlands.

@avalyset

avalyset commented Sep 1, 2026

Copy link
Copy Markdown
Author

Understood — I will drop the Rust tests here as soon as C2SP/x509-limbo#658 lands.

avalyset and others added 2 commits September 2, 2026 09:33
C2SP/x509-limbo#658 landed, so
rfc5280::nc::permitted-empty-sequence-excluded-nonempty and
rfc5280::nc::permitted-nonempty-excluded-nonempty cover these two cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI pins x509-limbo to 972626160c26b45426bbd8c935a605219bd93207
(2026-08-27), which predates C2SP/x509-limbo#658, so the new cases are
not in the vectors CI runs against yet. Without this test
extension.rs:754-756 is uncovered and the coverage gate fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@avalyset

avalyset commented Sep 2, 2026

Copy link
Copy Markdown
Author

Removed test_ca_name_constraints_non_empty_permitted_subtrees, which rfc5280::nc::permitted-nonempty-excluded-nonempty now covers.

test_ca_name_constraints_empty_permitted_subtrees has to stay for now. fetch-vectors pins x509-limbo to 972626160c26b45426bbd8c935a605219bd93207 which predates the merge of C2SP/x509-limbo#658 on 2026-09-01, so neither new case is in the vectors CI actually runs — dropping the test takes extension.rs:754-756 to 99% and fails the coverage gate. It can go once the pin moves.

test_ca_name_constraints_empty_excluded_subtrees stays regardless: no limbo case covers a non-empty permittedSubtrees with an empty excludedSubtrees, which is the second is_empty check in this PR. I can add that one to limbo too if you would rather have it there.

@alex

alex commented Sep 2, 2026

Copy link
Copy Markdown
Member

#15580 has the fix, but there's a chicken and egg situation, if you cherry pick that it solves this.

pyca-boringbot[bot] and others added 2 commits September 2, 2026 14:31
Cherry-picked from pyca#15580 at Alex's suggestion on pyca#15560: the
new pin 21cc053 is the merge commit for C2SP/x509-limbo#658, so the two new
name-constraint testcases are in the vectors CI runs.

(cherry picked from commit 319a8a4)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pin now points at 21cc053, which carries
rfc5280::nc::permitted-empty-sequence-excluded-nonempty, so the branch this test
guarded is exercised by the limbo vectors CI runs.

test_ca_name_constraints_empty_excluded_subtrees stays: no limbo case covers a
non-empty permittedSubtrees with an empty excludedSubtrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@avalyset

avalyset commented Sep 2, 2026

Copy link
Copy Markdown
Author

Cherry-picked 319a8a4 from #15580, which moves the pin to 21cc053 — the merge commit for the limbo PR. With those vectors in CI, test_ca_name_constraints_empty_permitted_subtrees is covered by rfc5280::nc::permitted-empty-sequence-excluded-nonempty and is now removed.

test_ca_name_constraints_empty_excluded_subtrees still stays: no limbo case covers a non-empty permittedSubtrees with an empty excludedSubtrees. I can add that one to limbo as well if you would rather have it there.

@alex

alex commented Sep 2, 2026 via email

Copy link
Copy Markdown
Member

@woodruffw

Copy link
Copy Markdown
Member

I flagged this in C2SP/x509-limbo#660 as well, but noting here too: AFAICT basically zero validators conform to this ATM.

That also goes for the case added in C2SP/x509-limbo#658:

https://x509-limbo.com/testcases/rfc5280/#rfc5280ncpermitted-empty-sequence-excluded-nonempty

@woodruffw

Copy link
Copy Markdown
Member

For context, that would be consistent with that the validator currently does, i.e. we don't target requirements that are widely ignored by other validators:

# Tests that fail based on a strict reading of RFC 5280
# but are widely ignored by validators.
"pedantic-rfc5280",

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants