Skip to content

fix(security)!: replace trustSelfSignedCertificates with ServerTrust (CWE-295) - #3

Merged
jpurnell merged 2 commits into
mainfrom
fix/tls-trust
Oct 4, 2026
Merged

jpurnell merged 2 commits into
mainfrom
fix/tls-trust

Conversation

@jpurnell

@jpurnell jpurnell commented Oct 4, 2026

Copy link
Copy Markdown
Owner

What was wrong

HTTPSSETransport, StreamableHTTPTransport and WebSocketTransport each took trustSelfSignedCertificates: Bool. Passing true set NIOSSL's certificateVerification to .none. That does not trust a self-signed certificate — it stops checking certificates, so the connection accepts anything presented by anyone able to answer it (CWE-295). The quality gate's new security.tls-disabled rule flags all three sites as errors.

What replaces it

One public type, ServerTrust, taken by all three transports as serverTrust: (default .system):

verifies against
ServerTrust.system the platform root store (unchanged default)
try ServerTrust.additionalRoots([...]) the platform root store plus supplied certificates
try ServerTrust.onlyRoots([...]) supplied certificates and nothing else — with a self-signed server's own certificate, a pin

Sources are .pem(String), .der([UInt8]), .pemFile(String), .derFile(String). They are read and parsed when the value is made; a bad path, unparseable text, or an empty list throws ServerTrustError there, and nothing falls back to the system roots. rootFingerprints exposes the SHA-256 of each supplied root.

Chain and hostname verification stay on in every mode. ServerTrust.makeTLSConfiguration() is the only place a client TLSConfiguration is built, and it never writes certificateVerification.

Decisions worth reviewing

  • Removed, not deprecated (breaking → 0.13.0). A deprecated flag that quietly began verifying would compile and then fail at run time; one that kept working would keep the hole. Each transport keeps the old label only as an @available(*, unavailable) initializer, so the old spelling is a compile error whose message gives the replacement.
  • No SHA-256 hash pin. It needs NIOSSL's custom verification callback, which neither AsyncHTTPClient nor WebSocketKit exposes, and which replaces chain validation rather than adding to it. A pin only some transports could enforce is worse than none; onlyRoots with the server's certificate is the pin all three can honour.
  • Supplied roots run on NIO's event loops on every platform. On Apple platforms AsyncHTTPClient defaults to Network.framework and translates the TLSConfiguration; that translation ignores additionalTrustRoots entirely. With supplied roots the client is built on MultiThreadedEventLoopGroup.singleton so NIOSSL enforces what the tests assert. .system keeps the platform stack — no change for callers who pass nothing.
  • A development certificate must now name its host (SAN). That was skipped before and is not skippable now. The changelog has the openssl commands.

MCPExplorer's "Trust self-signed certificates" toggle becomes a "Trusted certificate file" field, and now applies to WebSocket too.

Migration

Where the argument was false, delete it. Where it was true:

let trust = try ServerTrust.onlyRoots([.pemFile("/etc/mcp/dev-server.pem")])
let transport = StreamableHTTPTransport(url: url, serverTrust: trust)

No consumer in the sibling repositories passes the old argument (geo-audit constructs its transports without it), so nothing downstream breaks.

Tests

Written failing first. 26 new, 3 vacuous ones (#expect(Bool(true)) after constructing with the flag) deleted.

  • ServerTrustTests (15) — the NIOSSL configuration per mode: .fullVerification, exact trustRoots / additionalTrustRoots; fingerprints checked against an independent SHA-256; every failure mode; and the configuration each of the three transports actually uses.
  • ServerTrustWireTests (8) — an HTTPS stub on loopback with certificates minted at run time (no key committed). The right root connects; a different self-signed certificate, a leaf from another CA, a trusted certificate for another host, and the default against a self-signed server are each refused with a TLS error and zero requests reaching the server.
  • TrustedCertificateFileTests (3) — MCPExplorer.

Verification

  • swift test: 586 tests passed (578 + 8).
  • Deployed quality-gate --check all: PASSED, 46 of 46 checkers, 0 errors / 0 warnings.
  • New gate binary, --check safety --no-cache on a scratch copy: 3 security.tls-disabled errors on main, none on this branch; no other security.* findings.
  • Not verified locally: Linux. No container runtime on this machine — the Linux CI job on this PR is the check.

Not tagged; 0.13.0 is written up in the changelog only.

🤖 Generated with Claude Code

https://claude.ai/code/session_0114bFC1osyZq6nFD4QrXDD2

jpurnell and others added 2 commits October 3, 2026 23:49
… nothing

`trustSelfSignedCertificates: true` set NIOSSL's `certificateVerification` to
`.none` in all three network transports. That does not trust a self-signed
certificate; it accepts any certificate from anyone able to answer the
connection (CWE-295).

`ServerTrust` replaces it, one type for all three transports: `.system`,
`.additionalRoots(_:)`, `.onlyRoots(_:)`. The caller supplies the certificate
(PEM text, DER bytes, or a file of either) and it becomes a trust root; chain
and hostname verification stay on in every case, and no code path writes
`certificateVerification` at all. Sources are read and parsed when the value
is made, so an unreadable certificate is an error where the configuration is
written and nothing falls back to the system roots.

The old parameter is kept only as an `unavailable` initializer on each
transport, so a caller gets a compile error that says what to write instead
of a build that compiles and then behaves differently.

Supplied roots are enforced by NIOSSL on every platform. On Apple platforms
AsyncHTTPClient otherwise runs on Network.framework and translates the
TLSConfiguration for it, and that translation drops `additionalTrustRoots`
entirely; a client with supplied roots now runs on NIO's own event loops so
the configuration that is asserted is the one that is enforced.

No hash pin: neither AsyncHTTPClient nor WebSocketKit exposes NIOSSL's
verification callback, and a pin only some transports could enforce is worse
than none. `onlyRoots` with the server's own certificate is the pin.

MCPExplorer's "Trust self-signed certificates" toggle becomes a path to a
certificate file, and now applies to WebSocket too.

Tests handshake against a TLS server on loopback with certificates minted at
run time: the right root connects; a different self-signed certificate, a
leaf from another CA, and a trusted certificate for another host are each
refused with zero requests reaching the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114bFC1osyZq6nFD4QrXDD2
…ificates

CHANGELOG gains 0.13.0: what the old flag actually did, why it is removed
rather than deprecated, the before/after, and the two checks a development
server now has to pass (a SAN naming its host, a chain ending at a supplied
root) with the openssl commands to produce a certificate that does. It also
gains link-reference definitions for every version, which it never had.

README replaces the option in its example and adds a section on self-signed
and private-CA servers; the "Linux CI outstanding" line is corrected, since
CI has been green since 0.11.0.

DocC: TransportGuide gains "Self-Signed and Private-CA Servers", the
MigrationGuide gains the 0.13.0 step, and the catalogue's Transport topic
lists ServerTrust, ServerTrustError and the StreamableHTTPTransport it had
been missing. The ServerTrust doc-comment examples are self-contained so the
gate can compile them. HTTPSSETransport no longer claims TLS behaves
identically on macOS and Linux: that is true only for a transport given
roots, and the comment now says so.

0.13.0 is written up but not tagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114bFC1osyZq6nFD4QrXDD2
@jpurnell
jpurnell merged commit f5bed04 into main Oct 4, 2026
1 check passed
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.

1 participant