Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 26 additions & 0 deletions dio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ See the [Migration Guide][] for the complete breaking changes list.**

*None.*

## 5.10.0

- **Security:** `IOHttpClientAdapter.validateCertificate` now fires between
the TLS handshake and the first HTTP byte instead of after the response
head, 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.
- Behavioral change: validation now runs once per TCP connection rather
than once per request, matching `dio_http2_adapter`'s long-standing
semantics. Tests asserting "N approvals for N requests" should be
updated.
- The pre-emission path is active only when `createHttpClient` is not
supplied. With a custom `createHttpClient`, the callback continues to
run post-response (the previous 5.x behavior). Documented on
`validateCertificate` and `createHttpClient`.
- 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`.
- Raise the minimum Dart SDK to 3.5.0 (Flutter 3.24+). Required for the
`ConnectionTask.fromSocket` API used by the pre-emission path.
- Migrate `DioMixin` to `mixin class` syntax now that the SDK floor is
Dart 3.

## 5.9.2

- Fixes `kIsWeb` across different Flutter SDKs.
Expand Down
209 changes: 202 additions & 7 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
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

Expand All @@ -25,6 +26,30 @@ 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 +69,42 @@ 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.
///
/// When [createHttpClient] is **not** set, 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.
///
/// When [createHttpClient] **is** supplied, dio cannot install the
/// pre-emission hook on the user-built [HttpClient]; the callback then
/// runs after the response head arrives (the legacy 5.x behavior). For
/// pre-emission validation in that case, set `connectionFactory` on the
/// [HttpClient] you return.
///
/// Validation runs once per TCP connection, not per request. Connections
/// are pooled by [HttpClient], so multiple requests to the same host
/// within `idleTimeout` will produce one approval call.
///
/// 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].
///
/// 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 legacy
/// post-response path.
ValidateCertificate? validateCertificate;

HttpClient? _cachedHttpClient;
Expand Down Expand Up @@ -110,6 +163,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 @@ -174,7 +232,12 @@ class IOHttpClientAdapter implements HttpClientAdapter {
}
final responseStream = await future;

if (validateCertificate != null) {
if (validateCertificate != null && createHttpClient != null) {
// Legacy post-response validation: only runs when the user supplied
// a custom [createHttpClient]. In that case we cannot install the
// pre-emission [HttpClient.connectionFactory] without clobbering the
// user's client, so the callback runs after the response head has
// arrived. See the doc-comment on [validateCertificate].
final host = options.uri.host;
final port = options.uri.port;
final bool isCertApproved = validateCertificate!(
Expand Down Expand Up @@ -244,7 +307,139 @@ 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 TLS handshake and runs
/// [validateCertificate] *before* yielding the socket to [HttpClient],
/// so a rejected certificate cannot leak request bytes.
Future<ConnectionTask<Socket>> _pinnedConnectionFactory(
Uri url,
String? proxyHost,
int? proxyPort,
) async {
// Plain HTTP β€” no TLS handshake to gate.
if (url.scheme != 'https') {
if (proxyHost != null) {
return Socket.startConnect(proxyHost, proxyPort!);
}
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;

if (proxyHost != null) {
return _connectHttpsViaProxy(url, proxyHost, proxyPort!, validator);
}

final socketFuture = () async {
final ss = await SecureSocket.connect(
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) {
_validatePeerCertificate(ss, url.host, url.port, validator);
}
return ss as Socket;
}();
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.

}

Future<ConnectionTask<Socket>> _connectHttpsViaProxy(
Uri target,
String proxyHost,
int proxyPort,
ValidateCertificate? validator,
) async {
Socket? proxySocket;
bool cancelled = false;

final socketFuture = () async {
proxySocket = await Socket.connect(proxyHost, proxyPort);
if (cancelled) {
proxySocket!.destroy();
throw const SocketException('connection cancelled');
}

// HTTP/1.1 CONNECT tunnel preamble.
const crlf = '\r\n';
final preamble = StringBuffer()
..write('CONNECT ${target.host}:${target.port} HTTP/1.1$crlf')
..write('Host: ${target.host}:${target.port}$crlf')
..write(crlf);
proxySocket!.write(preamble.toString());

final completer = Completer<void>();
late StreamSubscription<List<int>> subscription;
subscription = proxySocket!.listen(
(event) {
if (completer.isCompleted) {
return;
}
final response = ascii.decode(event);
final statusLine = response.split(crlf).first;
if (statusLine.contains(' 200 ')) {
completer.complete();
} else {
completer.completeError(
SocketException(
'Proxy CONNECT failed: $statusLine '
'(host=${target.host}, port=${target.port})',
),
);
}
},
onError: (Object e, StackTrace s) {
if (!completer.isCompleted) {
completer.completeError(e, s);
}
},
);
try {
await completer.future;
} finally {
await subscription.cancel();
}

final ss = await SecureSocket.secure(
proxySocket!,
host: target.host,
supportedProtocols: const ['http/1.1'],
onBadCertificate: validator == null ? null : (_) => true,
);
if (validator != null) {
_validatePeerCertificate(ss, target.host, target.port, validator);
}
return ss as Socket;
}();

return ConnectionTask.fromSocket(socketFuture, () {
cancelled = true;
proxySocket?.destroy();
});
}

void _validatePeerCertificate(
SecureSocket ss,
String host,
int port,
ValidateCertificate validator,
) {
final cert = ss.peerCertificate;
if (!validator(cert, host, port)) {
ss.destroy();
throw _BadCertificateException(host, port, cert);
}
}
}
3 changes: 1 addition & 2 deletions dio/lib/src/dio_mixin.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.

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.

Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ import 'transformer.dart';

part 'interceptor.dart';

// TODO(EVERYONE): Use `mixin class` when the lower bound of SDK is raised to 3.0.0.
abstract class DioMixin implements Dio {
abstract mixin class DioMixin implements Dio {
/// The base request config for the instance.
@override
late BaseOptions options;
Expand Down
2 changes: 1 addition & 1 deletion 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+.

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ repository: https://github.com/cfug/dio/blob/main/dio
issue_tracker: https://github.com/cfug/dio/issues

environment:
sdk: '>=2.18.0 <4.0.0'
sdk: '>=3.5.0 <4.0.0'

dependencies:
async: ^2.8.2
Expand Down
19 changes: 19 additions & 0 deletions dio/test/_pinning/server_cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIUaIC3vR7spGRPzpupdBHLOndap4QwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMDE0MjA1N1oXDTM2MDUw
NzE0MjA1N1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEApLUCQLEMhQ5M43KR/hpLLWnVOqSfvqFjFUGbVMWUgISl
YaHhbzlmFs+CyZrU0dd0yiJSu5WLAqY1acPch19MkVGnRIGp1Q2GMnGwQ46ai8BG
A3TG7gdZ25bHZbcqFPZKSHGE2yLZrfm3dNuWyj0QfRB9XJj6yqAOVbzuL48AOw1N
kf9f+T7UIKX1tJouHBFidVFZAS8rZkQ4RWZHW4hgF2pooQ8hPClZvVQHLQMvddrC
WkaBiRMvUjqX/VcqwcpCovU39thpAhkL9SGq1PHPsRkIyhpdgDlIbQPzqEQOYB6+
EStxzra+Ys92z7qYEHdqoU1X6anckL70Uwg2j0TILwIDAQABo28wbTAdBgNVHQ4E
FgQU9F7u6WPX2diKewg2CH1w3u3PRyUwHwYDVR0jBBgwFoAU9F7u6WPX2diKewg2
CH1w3u3PRyUwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJw+isKRWS5t2zNNWLEkUgV8YrsIF0sn
lhwyK1dktJnBEEo99gU1iA/QjPbgD+fJHiViMooXi7ecE2hCu5tNK3QKzQgyY59B
mu23OM3YbByVwLXEw09tp2ZvSdnafKLDyb/JyvQJI+BvzdUeqLDeUwiuMxyvVDtD
Zp7Ktghbpo3itnQd8i5gQR7jRLoFmSvx0QHMotJHQ+aLsh13cuCQgLu2F1Ie2YHr
DxCAoL0FXrNALM5eOOIzdZaxnw/Z/i2jKumz1yv8UskcyMwzOxwMNmDPomXwPxQt
JKsnCy52ujrkt/qHyNb8Gl0N445gG2AeyIKNUCNXJC7lc2BWdw7Z55A=
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions dio/test/_pinning/server_key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCktQJAsQyFDkzj
cpH+GkstadU6pJ++oWMVQZtUxZSAhKVhoeFvOWYWz4LJmtTR13TKIlK7lYsCpjVp
w9yHX0yRUadEganVDYYycbBDjpqLwEYDdMbuB1nblsdltyoU9kpIcYTbItmt+bd0
25bKPRB9EH1cmPrKoA5VvO4vjwA7DU2R/1/5PtQgpfW0mi4cEWJ1UVkBLytmRDhF
ZkdbiGAXamihDyE8KVm9VActAy912sJaRoGJEy9SOpf9VyrBykKi9Tf22GkCGQv1
IarU8c+xGQjKGl2AOUhtA/OoRA5gHr4RK3HOtr5iz3bPupgQd2qhTVfpqdyQvvRT
CDaPRMgvAgMBAAECggEACHe3Y26WwFptzS933k8Mv5mrrmkwlCMTlV47J2W3AHp0
3Q58/js+mMOCAPwku2InyamooN+oBSuRyU19wTgibIkK9T1lEcCxTFcqDYVN0igI
PBldHCH6D2KdVHrCzV3cyMs3epxnAcDkjK1/1LH9iy+SAXTcKvqFOUGHP0rLRva5
6C9byDUMbnoTA1M3RY/EleEc9Qs8m/hQqt1uyO5x7o/h5pzlVzppuVTIlUKnfmtA
pwUpLQjtnC3QHRaqLzWQCV5WUTsb1JdA1ykU6XqZ2FMcq6akOukdxi1NV6cP49qN
V6q7KiqzbHT57T+mi+kWzVxMZglNS+DUSm/18aaK2QKBgQDbD5lk/d2PX7ZGh0RD
Up4p1AnqF5PNsiGIbo2twkBYrd5NeDuosvM1qVfOKtOzaKwHtoqqUcGYMj3/vkCc
Bym+78Ud2X7VrQ+WSNERjqF5fB19Dp2PxBFIbJn2ZTATZ01JNna5E1KXNPg0Uq7q
GvpcZM6SWgZvAAXBY5faPzdTJwKBgQDAexExVBZs7bn3evhw3hR93vf8nO17Jszg
mutVsNfd1VxwgdpVb6d9qFl/7LpuAI4kiB3F+HH7GVPNcdJyCObwNKFs/M1RqDRA
HQAGLyODWW97+MNpUX7KJ8zL5i2GBM1dKlyjl4AlIepPZpd4UPAVJ4VnkZPaHHNm
772YdKNnuQKBgQC8FQeiM2mgqQ/qPEd6+ht8VGbyy62GpZOu/SS99JOk0BuXLk0i
ygqRn0UZaaH8XzmdIbirBakPkMu0odf9XxUTr+/xcgU5Vu6UHQ8MYQb5NHxpHDxh
7HjeUwhaCoUdk8tCufVzcEiwNLWKzxcJP4KIA7Fs7MirUzydz3HY3AAXbwKBgD8Z
2WgPhg6N3NKKKNpvo84kA654D864yJ+1igMcp5gc82Ia3+X0ZbdnMngitneLjQ3i
5cfaDBvikLugXfpueq8ywd0F/5WOjBqcpz5fw8ey0T6WLhHf2q4RYC1UN4ZhGqY5
Vgd3ilYiCTugiXWzKxH0U+LBMfRmMVsEq2ZIjq1ZAoGAdppPyGZlNKzkbK29cX/F
AEjGNmEtNwUxpVZHyd9awxKY6G0WcsVFXL1AMB9uJWqFwjppg3sZHWdxr1mFGLwK
PT7TEg1KEESqQkklLzDaU6CNzFx90U/4NAP4m2WDbr08yf8c65pr/1DPvEBnQQX0
T6d48kjMfGmnhoRhNRh+fr8=
-----END PRIVATE KEY-----
Loading