Skip to content

quic: a client common name carrying a control character is refused, as TCP refuses it - #377

Open
MDA2AV wants to merge 1 commit into
mainfrom
fix/200-quic-cn-control-chars
Open

MDA2AV wants to merge 1 commit into
mainfrom
fix/200-quic-cn-control-chars

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Fixes #200.

What was wrong. iq_record_subject kept a client certificate's CN whenever it was non-empty, fit peer_cn, and held no NUL. It had no control-character check. TlsSession.ExtractCommonName refuses any control character on TCP. So the same certificate had no name over TCP but was named over QUIC, and QuicEngineConnection.PeerCommonName, the value documented for authorizing on, could carry a CR/LF into whatever the handler logged next.

What changed. In iq_record_subject, a new iq_has_control check replaces the NUL test.

How the rule matches TCP's:

  • Both stacks take the CN from ASN1_STRING_to_UTF8, in the same BoringSSL (5b83e5d). That function decodes every string type with a validating decoder (a T61String is read as Latin-1) and re-encodes it, so both stacks see the same valid UTF-8.
  • TCP decodes that to UTF-16 and refuses on char.IsControl, which is exactly U+0000-U+001F and U+007F-U+009F.
  • In valid UTF-8, those code points are exactly the bytes 00-1F and 7F, plus the byte pairs C2 80 through C2 9F. A C2 byte there is always a lead byte.
  • So the byte test refuses exactly what TCP refuses. NUL is included, which is why the memchr went.

peer_subject is unchanged: X509_NAME_oneline escapes these bytes. PeerCommonName's doc now lists control characters among the reasons it is null.

Native build. libioxide_ngtcp2.so was rebuilt with scripts/build-ngtcp2-native.sh at its pinned refs (ngtcp2 7f7e24b, picotls f07f1c8, BoringSSL 5b83e5d), using the script's default WORK.

  • Reproduction. Built the same way from unchanged main, the script reproduces the shipped .so byte for byte. So this .so differs from main's only by the shim change.
  • Result. 2,974,032 bytes, the same as before.
    • The exported symbols are identical in name and size.
    • .text grew by 64 bytes and .eh_frame shrank by 8.
    • 842 of the 888 exports moved by +112 bytes, and the 46 that come before the change did not move.
    • No export was added or changed, so the ABI is unchanged.

Tests. New, in E2E QuicIdentityCapTests, next to the CN-cap tests:

  • mtls/quic: a common name carrying CRLF is refused, and the subject escapes it. This is the Tls suite's IdentitySubjectTests test of the same name, run over QUIC. The CN is alice\r\nauthorized=root. PeerCommonName is null, and PeerSubject carries alice\x0D\x0Aauthorized=root.

  • mtls/quic: a common name is refused for exactly the characters TCP refuses. Eight CNs, on both sides of each edge:

    • refused: TAB, ESC, DEL, U+0085, U+009F;
    • read back: U+00A0, ë, ÿ.

    The expected verdict comes from char.IsControl, TCP's own predicate.

  • The ordinary-CN control is the existing mtls/quic: control - a client whose name fits is named by both the subject and the CN.

  • Helpers:

    • The handler percent-encodes the CN in its response, because a CN can contain the line separator the reader splits on.
    • SignedByTestCa also takes an X500DistinguishedName.
    • CommonNamed builds a UTF8String CN that a subject string could not spell.

Confirmed both ways:

  • Without the fix (tests added, main's .so):
    • FAIL mtls/quic: a common name carrying CRLF is refused, and the subject escapes it: a CN carrying CR/LF must not be reported as an identity, got: alice\u000D\u000Aauthorized=root.
    • FAIL ... refused for exactly the characters TCP refuses: tab\u0009here: the TCP rule gives no name, QUIC read tab\u0009here; .... The same failure appears for ESC, DEL, U+0085 and U+009F. The three rows that should be read back already matched.
  • With the fix: both pass.

Suites.

  • E2E: 294 passed, 0 failed, 5 pending (main: 292 passed, 5 pending).
  • Http: 88 passed (main: 88).
  • Chaos: 79 passed, 1 pending (main: the same).
  • No new build warnings.

Overlap with #376 (the fix for #349). That PR also rebuilds libioxide_ngtcp2.so. git merge-tree of the two branches auto-merges the shim (the result carries both fixes and compiles), and only the .so conflicts, since it is binary. Whichever PR merges second needs scripts/build-ngtcp2-native.sh rerun on the merged shim, and the resulting .so committed.

All eight together. This batch's PRs (#376 to #383) were merged onto main 71aab91 in one scratch tree. The only conflict is the binary libioxide_ngtcp2.so between #376 and #377, rebuilt there from the merged shim with the script's defaults. Everything builds, and Unit 102, E2E 309, Http 88, Chaos 80, Tls 169 (14 kTLS cases skipped) and File 5 pass, with 0 failed and 0 pending. The repo's only pending test left is the Redis suite's (#336).

Noticed, not changed.

  • QUIC still refuses a CN of 256 bytes or more, where TCP sets no limit. The existing tests pin that.
  • The Tls suite tests only the CR/LF case. The DEL and C1 edges are asserted here against TCP's predicate, not through a TCP handshake.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quic: a client CN carrying control characters is accepted, where TCP refuses it

1 participant