Skip to content

🐛 Fix validateCertificate to fire pre-emission for TLS pinning#2512

Open
realmeylisdev wants to merge 3 commits into
cfug:mainfrom
realmeylisdev:fix/2418-pre-emission-tls-pinning
Open

🐛 Fix validateCertificate to fire pre-emission for TLS pinning#2512
realmeylisdev wants to merge 3 commits into
cfug:mainfrom
realmeylisdev:fix/2418-pre-emission-tls-pinning

Conversation

@realmeylisdev
Copy link
Copy Markdown

@realmeylisdev realmeylisdev commented May 10, 2026

Fixes #2418.

Summary

IOHttpClientAdapter.validateCertificate runs only after HttpClientRequest.close() has flushed the request method, headers, and body to the socket. The callback can prevent the response from being delivered to the caller, but it cannot prevent leakage of outbound request data — defeating the canonical purpose of certificate or public-key pinning.

This PR makes validateCertificate fire between the TLS handshake and the first HTTP byte for direct HTTPS connections by installing a custom HttpClient.connectionFactory (Dart 3.5+ ConnectionTask.fromSocket). When the callback returns false, the request body never leaves the client.

Reproducing the bug (on main today)

The bug is structural — no exploit setup needed.

  1. Read _fetch in dio/lib/src/adapters/io_adapter.dart on main:
    Future<HttpClientResponse> future = request.close();  // sends body
    ...
    final responseStream = await future;                  // response head arrives
    
    if (validateCertificate != null) {                    // <-- runs HERE, too late
      final bool isCertApproved = validateCertificate!(
        responseStream.certificate, host, port,
      );
      if (!isCertApproved) {
        throw DioException.badCertificate(...);
      }
    }
  2. By the time the callback runs, the request has already been transmitted and the server has begun replying. The library can block the response from being returned to the app, but the credentials/tokens/body are already on the wire to an attacker who presented any publicly trusted certificate for the wrong host.

Demonstrable end-to-end:

  1. Start a local HttpServer.bindSecure('localhost', 0, ctx) with a self-signed cert. Record bytes received per request.
  2. Configure dio:
    dio.httpClientAdapter = IOHttpClientAdapter(
      validateCertificate: (cert, host, port) => false,
    );
  3. await dio.post('https://localhost:\$port/leak', data: 'SENSITIVE');
  4. On main: server records bytesReceived > 0 (the payload reached it).
  5. With this PR: server records bytesReceived == 0, the request handler is never invoked, and the caller gets DioException.badCertificate from inside openUrl — before any byte is written.

This is encoded as the load-bearing regression test in dio/test/pinning_test.dart:

test('rejection blocks request body emission', () async {
  ...
  expect(error.type, DioExceptionType.badCertificate);
  expect(bytesReceived, 0);
  expect(requestsHandled, 0);
});

Approach

Install HttpClient.connectionFactory in _createHttpClient whenever validateCertificate != null and createHttpClient is not supplied. The factory:

  1. Plain HTTP (url.scheme != 'https') — short-circuits to Socket.startConnect. Pre-emission validation is skipped (no TLS handshake to gate); the post-response block still fires with a null cert.
  2. HTTPS direct (no proxy) — SecureSocket.connect(...) with supportedProtocols: ['http/1.1'], runs the user's validator, returns ConnectionTask.fromSocket(...). On rejection, throws a private _BadCertificateException (a HandshakeException subtype carrying the cert) — _fetch catches it and converts to DioException.badCertificate(error: cert) so callers see the same exception type as before.
  3. HTTPS via proxy — returns a plain socket to the proxy and lets HttpClient perform its own CONNECT tunnel and TLS handshake (we cannot pre-emption-validate proxy connections because HttpClient writes its own CONNECT over whatever socket we return). Validation runs only via the post-response block in this case — documented as a known limitation.

The post-response validation block in _fetch is retained and runs unconditionally when validateCertificate != null, providing defense in depth:

  • On the direct-HTTPS pre-emission path it is redundant (same cert, idempotent for pinning) — callbacks with side effects will see one extra call per request, documented.
  • It is the only line of defense for paths where pre-emission did not run: custom createHttpClient, late-mutation of validateCertificate after the cached client was built, and HTTPS through a proxy.

On the pre-emission path, validateCertificate is the sole gate for certificate trust — system / CA validation is bypassed (equivalent to HttpClient.badCertificateCallback: (_, _, _) => true in the existing example). This lets self-signed and pinned-CA setups work without supplying createHttpClient. Documented on the doc-comment.

Behavioral changes

  • For direct HTTPS pinning, validation happens before request emission. The byte-leak documented in validateCertificate callback fires after request body is sent, so true TLS-pinning is impossible #2418 is fixed for this case.
  • The pre-emission path is per TCP connection (matches dio_http2_adapter); the post-response path is per request.
  • HTTPS through a proxy still uses post-response validation only (HttpClient owns the CONNECT + TLS path).
  • Min Dart SDK raised to 3.5.0 (Flutter 3.24+, August 2024). Required for ConnectionTask.fromSocket. dio_test's SDK constraint bumped to match.
  • abstract class DioMixin migrated to abstract mixin class DioMixin, resolving the pre-existing TODO in dio/lib/src/dio_mixin.dart.
  • example_dart/lib/certificate_pinning.dart is simplified — the SecurityContext(withTrustedRoots: false) + badCertificateCallback: (_, _, _) => true workaround is no longer needed.
  • The DioException.badCertificate stack-trace test in dio/test/stacktrace_test.dart is updated to use the createHttpClient escape hatch, since the new factory path can't be exercised against the existing MockHttpClient (no stub for connectionFactory=).
  • dio/test/_pinning/ contains a committed self-signed keypair for the local HttpServer.bindSecure test; a README documents the regeneration command.

Edge cases acknowledged

Case Behavior
createHttpClient supplied Factory not installed; validation runs post-response only (legacy 5.x behavior).
Late mutation of validateCertificate (after first request) Factory was gated at construction; pre-emission does not retro-apply. Post-response validation runs normally.
HTTPS through a proxy Factory returns plain socket to proxy; HttpClient does CONNECT + TLS itself. Pre-emission is bypassed; post-response block validates. Documented.
Authenticated HTTP proxy (Proxy-Authorization) HttpClient.findProxy / HttpClient.addProxyCredentials continue to work because we delegate CONNECT to HttpClient.
SecurityContext with mTLS + validateCertificate without createHttpClient mTLS context is not used on the pre-emission path. Recommend createHttpClient for mTLS + pinning.
HTTPS → HTTP redirect Factory invoked with http://; short-circuits. Same as today.
Anonymous-DH cipher (no peer cert) validateCertificate(null, host, port) invoked. Same as 5.9.x.
Connection pool reuse Pre-emission runs once per pooled connection; post-response runs once per request.
Plain HTTP Pre-emission skipped; post-response fires with null cert (matches 5.9.x).

New Pull Request Checklist

  • I have read the Documentation
  • I have searched for a similar pull request in the project and found none
  • I have updated this branch with the latest `main` branch to avoid conflicts (via merge from master or rebase)
  • I have added the required tests to prove the fix/feature I'm adding
  • I have updated the documentation (if necessary)
  • I have run the tests without failures
  • I have updated the `CHANGELOG.md` in the corresponding package

Test plan

  • `cd dio && dart test` — 222 tests pass, 6 skipped (`tls` tag).
  • `cd dio && dart analyze lib/` — no issues. (Two pre-existing `unreachable_switch_default` warnings in `test/transformer_test.dart` surfaced by the SDK bump; they are unrelated to this fix.)
  • `'rejection blocks request body emission'` — load-bearing regression test for validateCertificate callback fires after request body is sent, so true TLS-pinning is impossible #2418. Fails on `main`, passes here. Asserts `bytesReceived == 0` and the request handler is never invoked.
  • `'approval lets the request through'` — happy path.
  • `'createHttpClient escape hatch keeps post-response validation'` — locks in the fallback path.
  • `'plain http skips pre-emission validation'` — confirms HTTP short-circuit; post-response still fires with null cert.
  • `'validateCertificate set after construction still fires (legacy post-response path)'` — regression guard against the H2 issue surfaced in the code review.
  • `'through a CONNECT proxy (post-response validation)'` group — two tests exercising a local stub `CONNECT` proxy fixture, confirming proxy + pinning works (via post-response) and rejection raises `badCertificate`.
  • `'2 requests share connection (1 approval)'` (`tls` tag) — updated to match per-connection semantics of pre-emission.
  • Manual: `example_dart/lib/certificate_pinning.dart` against badssl.com after refreshing fingerprint via `scripts/prepare_pinning_certs.sh` — trusted host returns a response, bad-host throws `badCertificate`.

Known limitations / follow-ups

  • HTTPS through a proxy still leaks request bytes (post-response only). Pre-emission behind a proxy would require either intercepting at a lower layer than `HttpClient.connectionFactory` (not exposed) or rebuilding the HTTP/1.1 framing layer (out of scope for this PR — `dio_http2_adapter` already does this for h2). Users needing pinning through a proxy today should use `createHttpClient` and install a custom `HttpClient.connectionFactory`.
  • Cancellation during the pre-emission TLS handshake is not propagated to abort the in-flight `SecureSocket.connect` — the eventual socket is dropped on the floor. Pre-existing semantics weren't great either; not a regression. Follow-up issue welcome.

`IOHttpClientAdapter.validateCertificate` previously ran after
`HttpClientRequest.close()` had already flushed the request method,
headers, and body to the wire. The callback could prevent the response
from being delivered to the caller, but it could not prevent leakage of
outbound request data — defeating the canonical purpose of certificate
or public-key pinning.

Install an `HttpClient.connectionFactory` (when `validateCertificate`
is set and `createHttpClient` is not) that performs the TLS handshake,
runs `validateCertificate`, and only then yields the socket to
`HttpClient`. Rejection throws a private `_BadCertificateException` (a
`HandshakeException` subtype) that `_fetch` translates into
`DioException.badCertificate` — without a single byte of the request
ever leaving the client.

The legacy post-response validation block is preserved as a fallback
for the `createHttpClient` escape hatch, so users who supply a custom
`HttpClient` keep today's degraded-but-not-broken semantics. Proxy
support is included via an HTTP/1.1 CONNECT-tunnel branch, mirroring
the pattern in `dio_http2_adapter`.

Behavior change: validation now runs once per TCP connection rather
than once per HTTP request, matching `dio_http2_adapter`'s existing
semantics. The existing `'2 requests == 2 approvals'` test is updated
accordingly. A new load-bearing regression test asserts that the
server receives zero bytes when `validateCertificate` returns false.

Min Dart SDK is raised to 3.5.0 for `ConnectionTask.fromSocket`. The
existing TODO migrating `DioMixin` to `mixin class` syntax is resolved.

Fixes cfug#2418
…README

Three issues surfaced during self-review of cfug#2512:

H2: late mutation of `validateCertificate` silently no-oped. The gate
on the legacy post-response block was `createHttpClient != null`, so
a user who set `validateCertificate` *after* the `HttpClient` was first
cached saw the callback never fire. Drop the gate so the post-response
block runs whenever `validateCertificate` is non-null — redundant on
the direct-HTTPS pre-emission path (the same cert was already approved
by the connectionFactory hook, idempotent for pinning), but the only
defense for late-mutation, proxy, and `createHttpClient` paths.

H1+M1: the manual `CONNECT`-tunnel inside `_connectHttpsViaProxy` was
broken — `HttpClient` writes its own `CONNECT` over whatever socket the
factory returns, so we ended up issuing two `CONNECT` requests and the
TLS handshake corrupted. (Discovered by the new proxy test failing.)
Drop the manual tunnel entirely; the factory's proxy branch now returns
a plain `Socket.startConnect(proxyHost, proxyPort)` and `HttpClient`
handles `CONNECT` + TLS itself. Proxy + pinning therefore falls back to
post-response validation only — documented as a known limitation; for
pre-emission behind a proxy, users must supply `createHttpClient` and
configure `HttpClient.connectionFactory` themselves. Add two tests that
exercise the proxy code path through a local stub `CONNECT` proxy
fixture.

Add `dio/test/_pinning/README.md` documenting the self-signed cert
regeneration command, since the committed pair has a 10-year validity
and a future maintainer will need to rotate it.

Also drop the redundant `as Socket` cast on the direct HTTPS branch
and consolidate the dangling `## Unreleased *None.*` CHANGELOG header
with the 5.10.0 entry — entries now live under `Unreleased` until the
release script cuts them.
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.

Please revert since we don't match the requirement here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reverted in 8a66e1e — back to abstract class DioMixin implements Dio with the original TODO(EVERYONE): Use \mixin class` when the lower bound of SDK is raised to 3.0.0.` comment.

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.

What about other adapters?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The fix is intentionally scoped to IOHttpClientAdapter since that's the adapter with the bug. Quick rundown of the others:

  • plugins/http2_adapter — already does pre-emission validation correctly at connection_manager_imp.dart:133-150. This PR brings IOHttpClientAdapter to parity.
  • plugins/web_adapter — no validateCertificate field exists on the web adapter; TLS is browser-controlled and not introspectable from Dart. Not applicable.
  • plugins/native_dio_adapter — delegates to cronet_http / cupertino_http; TLS pinning is platform-native (Android NSC, iOS ATS/TrustKit), not a dio-level callback. Not applicable.

Happy to add this as a one-line cross-reference comment in io_adapter.dart if you'd like that captured in source.

Comment thread dio/pubspec.yaml
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.

Please revert.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reverted in 8a66e1e — back to sdk: '>=2.18.0 <4.0.0'. Same for dio_test/pubspec.yaml. The pre-emission factory no longer needs Dart 3.5+.

Comment thread dio/lib/src/adapters/io_adapter.dart Outdated
}
return ss;
}();
return ConnectionTask.fromSocket(socketFuture, () {});
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.

The implementation of ConnectionTask is simple, try to copy it into the code base to avoid bumping the SDK version too high.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed differently in 8a66e1e — literal vendoring is blocked because Dart 3.0 made ConnectionTask a final class (any vendored class _ConnectionTask implements ConnectionTask fails to compile from 3.0 onward), and ConnectionTask.fromSocket is Dart 3.5+ only.

Switched to SecureSocket.startConnect(...) (stable pre-2.18 API) which returns a real SDK ConnectionTask<SecureSocket>. We await task.socket once to validate the leaf cert, then return task; HttpClient re-awaits the same Future and gets the resolved socket immediately. Covariance handles the upcast to ConnectionTask<Socket>. No vendoring, no SDK floor change — same observable behavior.

@realmeylisdev
Copy link
Copy Markdown
Author

Pushed review fixes (23aa0c0):

  • H2 (late mutation): the legacy post-response validation block now runs unconditionally when validateCertificate is non-null. Setting the field after the first request now fires the callback (was silently no-oping). For direct HTTPS the callback fires twice (pre-emission + post-response, idempotent for fingerprint pinning) — documented.
  • H1 + M1 (proxy): the manual CONNECT tunnel in the factory was broken — HttpClient writes its own CONNECT over the socket we return, so we ended up issuing two CONNECT requests and the TLS handshake corrupted. Discovered by the new proxy test failing. Dropped the manual tunnel; HTTPS through a proxy now uses post-response validation only (HttpClient handles CONNECT + TLS). Added two proxy tests via a local stub CONNECT fixture.
  • Fixture README: added dio/test/_pinning/README.md with the openssl command to regenerate the 10-year self-signed cert.
  • Cleanups: dropped redundant as Socket cast; consolidated dangling ## Unreleased *None.* with the new entry; removed dart:convert import that's no longer used.

PR description updated to reflect the corrected scope (proxy = known limitation, post-response only).

Per @AlexV525:

1. Revert dio/pubspec.yaml + dio_test/pubspec.yaml min SDK back to
   `>=2.18.0 <4.0.0` (the bump to 3.5.0 is no longer needed).
2. Revert dio/lib/src/dio_mixin.dart to `abstract class DioMixin` with
   the original TODO comment.
3. Replace the Dart-3.5+ `ConnectionTask.fromSocket(...)` call in
   `_pinnedConnectionFactory` with `SecureSocket.startConnect(...)` and
   return the stock SDK [ConnectionTask] it produces. We await
   `task.socket` once to do the leaf-cert validation; HttpClient
   re-awaits the same `Future<SecureSocket>` and gets the resolved
   value immediately (Future await is idempotent).

   This works on every Dart >=2.18.0 because `SecureSocket.startConnect`
   has been in `dart:io` well before the minimum SDK, and the
   `ConnectionTask` generic is covariant in its type parameter so
   `ConnectionTask<SecureSocket>` is implicitly assignable to
   `ConnectionTask<Socket>` at the factory's return position. No
   vendoring or `implements ConnectionTask` is required — and
   `ConnectionTask` being `final class` from Dart 3.0 onward is no
   longer a problem since we never inherit from it.

   The maintainer's suggestion to literally "copy ConnectionTask into
   the code base" cannot be done across the Dart 3.0–3.4 window (final
   class blocks implements; fromSocket didn't exist yet), but
   `SecureSocket.startConnect` achieves the same end with zero new
   types and zero SDK floor change.

4. Drop the two CHANGELOG bullets that referenced the SDK bump and
   the mixin migration. The rest of the entry (the actual cfug#2418 fix)
   stays.

No test changes — the factory's observable behavior is unchanged
(approval yields a validated SecureSocket; rejection still throws
`_BadCertificateException` from the same point in `_fetch`'s try
block). All 222 tests pass; `dart analyze lib/` is clean.
@AlexV525
Copy link
Copy Markdown
Member

Still not covering previous comments

@realmeylisdev
Copy link
Copy Markdown
Author

@AlexV525 thanks for the review. Pushed 8a66e1e addressing all four points:

1. dio/pubspec.yaml (revert) ✅ Restored sdk: '>=2.18.0 <4.0.0'. Same for dio_test/pubspec.yaml.

2. dio/lib/src/dio_mixin.dart (revert) ✅ Back to abstract class DioMixin implements Dio with the // TODO(EVERYONE): Use 'mixin class' when the lower bound of SDK is raised to 3.0.0. comment intact.

3. dio/lib/src/adapters/io_adapter.dart line 384 (vendor ConnectionTask) ✅ Achieved the underlying goal (keep min SDK at 2.18) without literal vendoring. Quick note on the constraint and the workaround:

Literally copying ConnectionTask into the codebase isn't viable across the Dart 3.0–3.4 window — Dart 3.0 made ConnectionTask a final class, so any vendored class _ConnectionTask implements ConnectionTask would fail to compile on Dart 3.0+. The public factory ConnectionTask.fromSocket that I'd been using is itself Dart 3.5+ only.

The fix is to use SecureSocket.startConnect(...) — a stable dart:io API that predates dio's 2.18 floor — which returns a real SDK-built ConnectionTask<SecureSocket>. We await task.socket once to validate the leaf certificate, then return the same task; HttpClient re-awaits task.socket and gets the already-resolved socket immediately (Future awaits are idempotent). Covariance does the upcast from ConnectionTask<SecureSocket> to ConnectionTask<Socket> at the return position.

Diff for the affected branch is small:

  • before: await SecureSocket.connect(...) + ConnectionTask.fromSocket(socketFuture, () {})
  • after: await SecureSocket.startConnect(...) + await task.socket (for validation) + return task

4. "What about other adapters?" ✅ The fix is intentionally scoped to IOHttpClientAdapter because that's where the bug exists. For completeness:

  • plugins/http2_adapter — already does pre-emission validation correctly at connection_manager_imp.dart:133-150. No change needed; this PR brings IOHttpClientAdapter to parity.
  • plugins/web_adapter — no validateCertificate field on the web adapter (it's only on IOHttpClientAdapter). TLS is handled by the browser and is not introspectable from Dart. Not applicable.
  • plugins/native_dio_adapter — delegates to cronet_http / cupertino_http, where TLS pinning is platform-native (Android NSC, iOS ATS/TrustKit). Not exposed via a dio-level callback. Not applicable.

Happy to add a one-line cross-reference comment in io_adapter.dart pointing to this clarification if you'd like that captured in source.

All 222 tests pass; dart analyze lib/ is clean.

@AlexV525
Copy link
Copy Markdown
Member

Tests didn't seem passing.
Additionally, I'm considering ignoring these pinning test certs for Git for security concerns.

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.

validateCertificate callback fires after request body is sent, so true TLS-pinning is impossible

2 participants