Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions dio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ See the [Migration Guide][] for the complete breaking changes list.**
On web, timeout handling is best-effort because synchronous JavaScript work cannot be preempted.
- Fix `FormData.clone()` dropping `boundaryName` and `camelCaseContentDisposition`, so a retried multipart request now keeps the original options instead of silently falling back to the defaults.
- Fix `QueuedInterceptor` stalling its queue forever when the active request is cancelled while its callback is still pending (never calls `next`/`resolve`/`reject`), which blocked every subsequent request routed through the interceptor.
- **Security:** `IOHttpClientAdapter.validateCertificate` now fires between
the TLS handshake and the first HTTP byte for direct HTTPS connections
(no proxy, no custom `createHttpClient`), making it suitable for true
certificate or public-key pinning (issue #2418). Previously, an
attacker presenting a publicly trusted certificate for the wrong host
could receive the full request body and headers before the callback
aborted.
- The callback also continues to run **after the response head arrives**
as defense in depth β€” both invocations receive the same leaf
certificate, so for fingerprint-style pinning the second call is
idempotent. Callbacks with side effects (logging, metrics) will
observe one extra call per request on the direct-HTTPS path.
- HTTPS routed through a proxy continues to use the post-response
validation path only (HttpClient performs its own `CONNECT` tunnel and
TLS handshake, bypassing the pre-emission hook). For pre-emission
pinning behind a proxy, supply `createHttpClient` and configure
`HttpClient.connectionFactory` yourself.
- On the pre-emission path, `validateCertificate` is the sole gate for
certificate trust β€” system / CA validation is bypassed (matching what
users already configure via `badCertificateCallback: (_, _, _) => true`
in the existing pinning example), so self-signed and pinned-CA setups
work without supplying `createHttpClient`.

## 5.9.2

Expand Down
156 changes: 150 additions & 6 deletions dio/lib/src/adapters/io_adapter.dart

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.

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ typedef ValidateCertificate = bool Function(
int port,
);

/// Internal sentinel thrown from [IOHttpClientAdapter]'s connection factory
/// when [IOHttpClientAdapter.validateCertificate] rejects the peer certificate.
/// [_fetch] catches it and rethrows as [DioException.badCertificate].
class _BadCertificateException implements HandshakeException {
_BadCertificateException(this.host, this.port, this.cert);

final String host;
final int port;
final X509Certificate? cert;

@override
String get type => 'HandshakeException';

@override
String get message => 'validateCertificate returned false';

@override
OSError? get osError => null;

@override
String toString() => 'HandshakeException: $message (host=$host, port=$port)';
}

/// Creates an [IOHttpClientAdapter].
HttpClientAdapter createAdapter() => IOHttpClientAdapter();

Expand All @@ -44,14 +67,53 @@ class IOHttpClientAdapter implements HttpClientAdapter {

/// When this callback is set, [Dio] will call it every
/// time it needs a [HttpClient].
///
/// Note: supplying this disables the pre-emission TLS hook used by
/// [validateCertificate]. See [validateCertificate] for details.
CreateHttpClient? createHttpClient;

/// Allows the user to decide if the response certificate is good.
/// If this function is missing, then the certificate is allowed.
/// This method is called only if both the [SecurityContext] and
/// [badCertificateCallback] accept the certificate chain. Those
/// methods evaluate the root or intermediate certificate, while
/// [validateCertificate] evaluates the leaf certificate.
/// Allows the user to decide if the leaf certificate of the TLS connection
/// is good. If this function is missing, then the certificate is allowed.
///
/// For **direct HTTPS** connections (no proxy) with the default
/// [createHttpClient], this callback fires **before** the request body
/// is sent, immediately after the TLS handshake completes. Returning
/// `false` aborts the connection without leaking any request data and
/// surfaces as [DioException.badCertificate]. This makes the callback
/// suitable for certificate or public-key pinning.
///
/// The callback also runs **after the response head arrives** for the
/// same connection. The two invocations receive the same leaf
/// certificate, so for fingerprint-style pinning the second call is
/// idempotent. If your callback has side effects (logging, metrics),
/// expect to see them twice per request on the direct-HTTPS path.
///
/// **HTTPS through a proxy:** `HttpClient` performs its own `CONNECT`
/// tunnel and TLS handshake, so the pre-emission hook is bypassed.
/// Validation runs only post-response on this path β€” the request body
/// has already been transmitted by the time the callback is invoked.
/// For pre-emission pinning behind a proxy, use [createHttpClient] and
/// install your own `connectionFactory` on the returned client.
///
/// **Custom [createHttpClient]:** dio cannot install the pre-emission
/// hook on a user-built [HttpClient]; the callback runs post-response
/// (the legacy 5.x behavior). For pre-emission validation in that case,
/// set `connectionFactory` on the [HttpClient] you return.
///
/// On the pre-emission path, this callback is the **sole** gate for
/// certificate trust: system / CA validation is bypassed (the equivalent
/// of `HttpClient.badCertificateCallback: (_, _, _) => true` in the
/// existing example), so the callback receives every leaf cert and is
/// solely responsible for accepting or rejecting it. This matches what
/// users already configure manually for pinning and lets self-signed and
/// pinned-CA setups work without supplying [createHttpClient]. If you
/// do not intend to perform pinning, do not set this callback β€”
/// supplying any non-null value disables stdlib chain validation.
///
/// Any [SecurityContext] you set on a custom [HttpClient] is **not**
/// used by the pre-emission path. To combine mTLS or a custom trust store
/// with pinning, supply a [createHttpClient] and pin in the post-response
/// path.
ValidateCertificate? validateCertificate;

HttpClient? _cachedHttpClient;
Expand Down Expand Up @@ -110,6 +172,11 @@ class IOHttpClientAdapter implements HttpClientAdapter {
);
}
});
} on _BadCertificateException catch (e) {
throw DioException.badCertificate(
requestOptions: options,
error: e.cert,
);
} on SocketException catch (e) {
if (e.message.contains('timed out')) {
final Duration effectiveTimeout;
Expand Down Expand Up @@ -190,6 +257,18 @@ class IOHttpClientAdapter implements HttpClientAdapter {
}

if (validateCertificate != null) {
// Post-response validation runs unconditionally as defense in depth.
// On the pre-emission path it is redundant β€” the same cert was
// already approved by the [HttpClient.connectionFactory] hook β€” but
// it is the only line of defense for paths where pre-emission did
// not run: (a) when the user supplied a custom [createHttpClient],
// (b) when [validateCertificate] was set after the [HttpClient] was
// first cached, or (c) for HTTPS through a proxy (see the doc-comment
// on [validateCertificate]). For pinning the callback is idempotent
// so the redundancy is harmless; callbacks with side effects will
// observe one extra call per request on the direct-HTTPS path.
// On plain HTTP, [responseStream.certificate] is null and the user
// typically rejects nulls (matching the example).
final host = options.uri.host;
final port = options.uri.port;
final bool isCertApproved = validateCertificate!(
Expand Down Expand Up @@ -259,7 +338,72 @@ class IOHttpClientAdapter implements HttpClientAdapter {
return createHttpClient!();
}
final client = HttpClient()..idleTimeout = const Duration(seconds: 3);
if (validateCertificate != null) {
client.connectionFactory = _pinnedConnectionFactory;
}
// ignore: deprecated_member_use, deprecated_member_use_from_same_package
return onHttpClientCreate?.call(client) ?? client;
}

/// Custom connection factory that performs the TLS handshake and runs
/// [validateCertificate] *before* yielding the socket to [HttpClient],
/// so a rejected certificate cannot leak request bytes.
///
/// For HTTPS requests routed through a proxy, this factory cannot
/// pre-emption-validate: HttpClient writes its own `CONNECT` and performs
/// its own TLS handshake on top of the socket we return. In that case the
/// proxy branch returns a plain socket to the proxy and validation falls
/// back to the post-response check in [_fetch].
Future<ConnectionTask<Socket>> _pinnedConnectionFactory(
Uri url,
String? proxyHost,
int? proxyPort,
) async {
// Proxy: hand HttpClient a plain socket to the proxy and let it own the
// CONNECT-tunnel and TLS upgrade. The post-response block in [_fetch]
// performs validation in this path.
if (proxyHost != null) {
return Socket.startConnect(proxyHost, proxyPort!);
}

// Plain HTTP, no proxy: hand HttpClient the target socket.
if (url.scheme != 'https') {
return Socket.startConnect(url.host, url.port);
}

// Snapshot at call time so a runtime mutation of the public field
// is honored on every new connection.
final validator = validateCertificate;

// [SecureSocket.startConnect] returns a stock SDK [ConnectionTask] β€”
// available since well before dio's min SDK of 2.18 β€” so we don't
// need [ConnectionTask.fromSocket] (Dart 3.5+) and don't need to
// subclass/vendor [ConnectionTask] (which is `final class` from Dart
// 3.0). We await `task.socket` once here to do the leaf-cert check,
// then return the same task; [HttpClient] re-awaits `task.socket`
// and gets the already-resolved [SecureSocket] immediately.
//
// Covariance: [ConnectionTask] has no bound on its type parameter,
// so [ConnectionTask<SecureSocket>] is implicitly assignable to
// [ConnectionTask<Socket>] at the return position.
final task = await SecureSocket.startConnect(
url.host,
url.port,
supportedProtocols: const ['http/1.1'],
// When [validateCertificate] is set, defer all certificate
// validation to the user's callback. This makes self-signed and
// pinned-CA setups work without forcing the user to also supply
// [createHttpClient].
onBadCertificate: validator == null ? null : (_) => true,
);
if (validator != null) {
final ss = await task.socket;
final cert = ss.peerCertificate;
if (!validator(cert, url.host, url.port)) {
ss.destroy();
throw _BadCertificateException(url.host, url.port, cert);
}
}
return task;
}
}
Loading
Loading