From 1ce3ee27a73c662a1858b7909ce8dc61a10e6d2f Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Sun, 10 May 2026 20:38:35 +0500 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20Fix=20validateCertificate=20?= =?UTF-8?q?to=20fire=20pre-emission=20for=20TLS=20pinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 #2418 --- dio/CHANGELOG.md | 26 +++ dio/lib/src/adapters/io_adapter.dart | 209 +++++++++++++++++++++- dio/lib/src/dio_mixin.dart | 3 +- dio/pubspec.yaml | 2 +- dio/test/_pinning/server_cert.pem | 19 ++ dio/test/_pinning/server_key.pem | 28 +++ dio/test/pinning_test.dart | 140 ++++++++++++++- dio/test/stacktrace_test.dart | 61 +++---- dio_test/pubspec.yaml | 2 +- example_dart/lib/certificate_pinning.dart | 18 +- 10 files changed, 451 insertions(+), 57 deletions(-) create mode 100644 dio/test/_pinning/server_cert.pem create mode 100644 dio/test/_pinning/server_key.pem diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 134800694..d5e851cd1 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -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. diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart index 20553fef5..cc30fab6f 100644 --- a/dio/lib/src/adapters/io_adapter.dart +++ b/dio/lib/src/adapters/io_adapter.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -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(); @@ -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; @@ -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; @@ -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!( @@ -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> _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, () {}); + } + + Future> _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(); + late StreamSubscription> 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); + } + } } diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index 7145cf0cd..6f2483687 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -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; diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml index 9fbb9af78..59b2ae30a 100644 --- a/dio/pubspec.yaml +++ b/dio/pubspec.yaml @@ -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 diff --git a/dio/test/_pinning/server_cert.pem b/dio/test/_pinning/server_cert.pem new file mode 100644 index 000000000..4936a55ad --- /dev/null +++ b/dio/test/_pinning/server_cert.pem @@ -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----- diff --git a/dio/test/_pinning/server_key.pem b/dio/test/_pinning/server_key.pem new file mode 100644 index 000000000..55afd23a4 --- /dev/null +++ b/dio/test/_pinning/server_key.pem @@ -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----- diff --git a/dio/test/pinning_test.dart b/dio/test/pinning_test.dart index 97b785d5c..8eeafe658 100644 --- a/dio/test/pinning_test.dart +++ b/dio/test/pinning_test.dart @@ -152,11 +152,15 @@ void main() { ); test( - '2 requests == 2 approvals', + '2 requests share connection (1 approval)', () async { + // Validation is now per TCP connection (not per HTTP request) since + // the callback fires from the connectionFactory pre-emission rather + // than post-response. HttpClient pools connections, so back-to-back + // requests within idleTimeout (3s) share one connection and one + // approval. Matches dio_http2_adapter's long-standing semantics. int approvalCount = 0; final dio = Dio(); - // badCertificateCallback never called for trusted certificate dio.httpClientAdapter = IOHttpClientAdapter( validateCertificate: (cert, host, port) { approvalCount++; @@ -173,9 +177,139 @@ void main() { options: Options(validateStatus: (status) => true), ); expect(response.data, isNotNull); - expect(approvalCount, 2); + expect(approvalCount, 1); }, tags: ['tls'], ); + + group('pre-emission validation:', () { + late HttpServer secureServer; + late int bytesReceived; + late int requestsHandled; + + setUp(() async { + bytesReceived = 0; + requestsHandled = 0; + final ctx = SecurityContext() + ..useCertificateChain('test/_pinning/server_cert.pem') + ..usePrivateKey('test/_pinning/server_key.pem'); + secureServer = await HttpServer.bindSecure('localhost', 0, ctx); + secureServer.listen((req) async { + requestsHandled++; + try { + await for (final chunk in req) { + bytesReceived += chunk.length; + } + } finally { + req.response.statusCode = 200; + req.response.write('ok'); + await req.response.close(); + } + }); + }); + + tearDown(() async { + await secureServer.close(force: true); + }); + + test('rejection blocks request body emission', () async { + // Load-bearing regression test for #2418: when validateCertificate + // returns false, the request body must never reach the server. + final dio = Dio(); + dio.httpClientAdapter = IOHttpClientAdapter( + validateCertificate: (cert, host, port) => false, + ); + DioException? error; + try { + await dio.post( + 'https://localhost:${secureServer.port}/leak', + data: 'SENSITIVE-PAYLOAD', + ); + fail('did not throw'); + } on DioException catch (e) { + error = e; + } + // Allow any in-flight server activity to settle. + await Future.delayed(const Duration(milliseconds: 50)); + expect(error, isNotNull); + expect(error.type, DioExceptionType.badCertificate); + expect(bytesReceived, 0); + expect(requestsHandled, 0); + }); + + test('approval lets the request through', () async { + bool approverCalled = false; + final dio = Dio(); + dio.httpClientAdapter = IOHttpClientAdapter( + validateCertificate: (cert, host, port) { + approverCalled = true; + return true; + }, + ); + final res = await dio.post( + 'https://localhost:${secureServer.port}/echo', + data: 'hi', + options: Options(responseType: ResponseType.plain), + ); + expect(approverCalled, isTrue); + expect(res.statusCode, 200); + expect(res.data, 'ok'); + expect(requestsHandled, 1); + }); + + test('createHttpClient escape hatch keeps post-response validation', + () async { + // When createHttpClient is supplied, the connectionFactory cannot + // be installed without clobbering the user-built HttpClient. + // validateCertificate continues to fire post-response (legacy 5.x + // behavior). + final dio = Dio(); + dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () => HttpClient() + ..badCertificateCallback = (cert, host, port) => true, + validateCertificate: (cert, host, port) => false, + ); + DioException? error; + try { + await dio.post( + 'https://localhost:${secureServer.port}/legacy', + data: 'payload', + ); + fail('did not throw'); + } on DioException catch (e) { + error = e; + } + expect(error, isNotNull); + expect(error.type, DioExceptionType.badCertificate); + // Legacy path: server received the request before validation ran. + expect(requestsHandled, 1); + expect(bytesReceived, greaterThan(0)); + }); + }); + + test('plain http does not invoke validateCertificate', () async { + final server = await HttpServer.bind('localhost', 0); + try { + server.listen((req) { + req.response.statusCode = 200; + req.response.write('plain'); + req.response.close(); + }); + final dio = Dio(); + dio.httpClientAdapter = IOHttpClientAdapter( + validateCertificate: (cert, host, port) { + fail('validateCertificate must not run on plain HTTP'); + }, + ); + final res = await dio.get( + 'http://localhost:${server.port}/', + options: Options(responseType: ResponseType.plain), + ); + expect(res.statusCode, 200); + expect(res.data, 'plain'); + } finally { + await server.close(force: true); + } + }); }); } diff --git a/dio/test/stacktrace_test.dart b/dio/test/stacktrace_test.dart index b96b793f4..4daf8bb21 100644 --- a/dio/test/stacktrace_test.dart +++ b/dio/test/stacktrace_test.dart @@ -182,38 +182,39 @@ void main() async { test( DioExceptionType.badCertificate, () async { - await HttpOverrides.runWithHttpOverrides( - () async { - final dio = Dio() - ..options.baseUrl = 'https://does.not.exist' - ..httpClientAdapter = IOHttpClientAdapter( - validateCertificate: (_, __, ___) => false, - ); - - when(httpClientMock.openUrl('GET', any)).thenAnswer( - (_) async { - final request = MockHttpClientRequest(); - final response = MockHttpClientResponse(); - when(request.close()).thenAnswer((_) => Future.value(response)); - when(response.certificate).thenReturn(null); - return request; - }, - ); + // Exercise the legacy post-response validation path by supplying + // [createHttpClient]; the connectionFactory hook is intentionally + // skipped in that case (see [IOHttpClientAdapter.validateCertificate] + // and #2418), so the mock HttpClient need only stub [openUrl] / + // [request.close] / [response.certificate]. + final dio = Dio() + ..options.baseUrl = 'https://does.not.exist' + ..httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () => httpClientMock, + validateCertificate: (cert, host, port) => false, + ); - await expectLater( - dio.get('/test'), - throwsA( - allOf([ - isA(), - (DioException e) => e.type == DioExceptionType.badCertificate, - (DioException e) => e.stackTrace - .toString() - .contains('test/stacktrace_test.dart'), - ]), - ), - ); + when(httpClientMock.openUrl('GET', any)).thenAnswer( + (_) async { + final request = MockHttpClientRequest(); + final response = MockHttpClientResponse(); + when(request.close()).thenAnswer((_) => Future.value(response)); + when(response.certificate).thenReturn(null); + return request; }, - MockHttpOverrides(), + ); + + await expectLater( + dio.get('/test'), + throwsA( + allOf([ + isA(), + (DioException e) => e.type == DioExceptionType.badCertificate, + (DioException e) => e.stackTrace + .toString() + .contains('test/stacktrace_test.dart'), + ]), + ), ); }, testOn: '!browser', diff --git a/dio_test/pubspec.yaml b/dio_test/pubspec.yaml index 3033b41a9..138c8c2b0 100644 --- a/dio_test/pubspec.yaml +++ b/dio_test/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/cfug/dio/blob/main/dio_test issue_tracker: https://github.com/cfug/dio/issues environment: - sdk: '>=2.18.0 <4.0.0' + sdk: '>=3.5.0 <4.0.0' dependencies: dio: any diff --git a/example_dart/lib/certificate_pinning.dart b/example_dart/lib/certificate_pinning.dart index 08ee374fc..8221aa1ec 100644 --- a/example_dart/lib/certificate_pinning.dart +++ b/example_dart/lib/certificate_pinning.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; @@ -16,19 +14,13 @@ void main() async { // should look like this: 'ee5ce1dfa7a53657c545c62b65802e4272878dabd65c0aadcf85783ebb0b4d5c'; - // Don't trust any certificate just because their root cert is trusted + // As of dio 5.10.0, validateCertificate fires before the request body is + // sent (when createHttpClient is not supplied), so a rejected certificate + // blocks the request rather than just the response. dio.httpClientAdapter = IOHttpClientAdapter( - createHttpClient: () { - final client = HttpClient( - context: SecurityContext(withTrustedRoots: false), - ); - // You can test the intermediate / root cert here. We just ignore it. - client.badCertificateCallback = (cert, host, port) => true; - return client; - }, validateCertificate: (cert, host, port) { - // Check that the cert fingerprint matches the one we expect - // We definitely require _some_ certificate + // Check that the cert fingerprint matches the one we expect. + // We definitely require _some_ certificate. if (cert == null) { return false; } From 23aa0c057177ff31312d6355ddb2485ea94ff1c1 Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Mon, 11 May 2026 05:24:20 +0500 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20Address=20PR=20#2512=20revie?= =?UTF-8?q?w:=20late=20mutation,=20proxy=20honesty,=20fixture=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues surfaced during self-review of #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. --- dio/CHANGELOG.md | 33 +++-- dio/lib/src/adapters/io_adapter.dart | 189 +++++++++---------------- dio/test/_pinning/README.md | 21 +++ dio/test/pinning_test.dart | 203 ++++++++++++++++++++++++++- 4 files changed, 303 insertions(+), 143 deletions(-) create mode 100644 dio/test/_pinning/README.md diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index d5e851cd1..833e629ac 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -5,24 +5,23 @@ See the [Migration Guide][] for the complete breaking changes list.** ## Unreleased -*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`. + 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` diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart index cc30fab6f..267a9014b 100644 --- a/dio/lib/src/adapters/io_adapter.dart +++ b/dio/lib/src/adapters/io_adapter.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -77,21 +76,30 @@ class IOHttpClientAdapter implements HttpClientAdapter { /// 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. + /// 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. /// - /// 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. + /// 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. /// - /// 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. + /// **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 @@ -99,12 +107,14 @@ class IOHttpClientAdapter implements HttpClientAdapter { /// 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]. + /// 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 legacy - /// post-response path. + /// with pinning, supply a [createHttpClient] and pin in the post-response + /// path. ValidateCertificate? validateCertificate; HttpClient? _cachedHttpClient; @@ -232,12 +242,19 @@ class IOHttpClientAdapter implements HttpClientAdapter { } final responseStream = await future; - 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]. + 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!( @@ -314,19 +331,29 @@ class IOHttpClientAdapter implements HttpClientAdapter { return onHttpClientCreate?.call(client) ?? client; } - /// Custom connection factory that performs TLS handshake and runs + /// 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> _pinnedConnectionFactory( Uri url, String? proxyHost, int? proxyPort, ) async { - // Plain HTTP — no TLS handshake to gate. + // 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') { - if (proxyHost != null) { - return Socket.startConnect(proxyHost, proxyPort!); - } return Socket.startConnect(url.host, url.port); } @@ -334,112 +361,26 @@ class IOHttpClientAdapter implements HttpClientAdapter { // 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]. + // 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); + final cert = ss.peerCertificate; + if (!validator(cert, url.host, url.port)) { + ss.destroy(); + throw _BadCertificateException(url.host, url.port, cert); + } } - return ss as Socket; + return ss; }(); return ConnectionTask.fromSocket(socketFuture, () {}); } - - Future> _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(); - late StreamSubscription> 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); - } - } } diff --git a/dio/test/_pinning/README.md b/dio/test/_pinning/README.md new file mode 100644 index 000000000..059c9d041 --- /dev/null +++ b/dio/test/_pinning/README.md @@ -0,0 +1,21 @@ +# Test fixtures: pinning + +`server_cert.pem` and `server_key.pem` are a self-signed TLS keypair used by +`pinning_test.dart`'s pre-emission validation tests to spin up local +`HttpServer.bindSecure` instances on `localhost`. + +These files are committed to keep the test suite hermetic (no network +dependency for the load-bearing regression test that asserts request bytes +do not leak when `validateCertificate` rejects). + +The current pair has 10-year validity. To regenerate when it expires: + +```sh +openssl req -x509 -newkey rsa:2048 \ + -keyout server_key.pem -out server_cert.pem \ + -days 3650 -nodes \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +``` + +The private key is for localhost-only testing and has no production value. diff --git a/dio/test/pinning_test.dart b/dio/test/pinning_test.dart index 8eeafe658..c9f6659ae 100644 --- a/dio/test/pinning_test.dart +++ b/dio/test/pinning_test.dart @@ -1,5 +1,8 @@ @TestOn('vm') +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; @@ -287,7 +290,12 @@ void main() { }); }); - test('plain http does not invoke validateCertificate', () async { + test('plain http skips pre-emission validation', () async { + // The pre-emission factory short-circuits non-https URLs to + // [Socket.startConnect] without invoking the validator. The + // post-response block still fires (with a null cert, matching 5.9.x + // semantics), so the user's callback decides what to do with null — + // the typical pinning callback rejects it and the request fails. final server = await HttpServer.bind('localhost', 0); try { server.listen((req) { @@ -295,10 +303,12 @@ void main() { req.response.write('plain'); req.response.close(); }); + X509Certificate? observedCert; final dio = Dio(); dio.httpClientAdapter = IOHttpClientAdapter( validateCertificate: (cert, host, port) { - fail('validateCertificate must not run on plain HTTP'); + observedCert = cert; + return true; // accept the null cert so we can assert observability }, ); final res = await dio.get( @@ -307,9 +317,198 @@ void main() { ); expect(res.statusCode, 200); expect(res.data, 'plain'); + // Only the post-response call fires (and with null cert because + // there is no TLS handshake on plain HTTP). + expect(observedCert, isNull); } finally { await server.close(force: true); } }); + + test('validateCertificate set after construction still fires (legacy ' + 'post-response path)', () async { + // Late-mutation regression guard: a user who constructs the adapter + // without validateCertificate and sets it later should still have the + // callback fire (via the legacy post-response path), even though the + // pre-emission factory could not be installed. + final ctx = SecurityContext() + ..useCertificateChain('test/_pinning/server_cert.pem') + ..usePrivateKey('test/_pinning/server_key.pem'); + final server = await HttpServer.bindSecure('localhost', 0, ctx); + server.listen((req) { + req.response.statusCode = 200; + req.response.write('ok'); + req.response.close(); + }); + try { + final adapter = IOHttpClientAdapter( + // ignore: deprecated_member_use_from_same_package + onHttpClientCreate: (client) => + client..badCertificateCallback = (cert, host, port) => true, + ); + final dio = Dio()..httpClientAdapter = adapter; + // Warm the cached HttpClient with no validator set. + final ok = await dio.get( + 'https://localhost:${server.port}/', + options: Options(responseType: ResponseType.plain), + ); + expect(ok.statusCode, 200); + // Now set the validator. + adapter.validateCertificate = (cert, host, port) => false; + await expectLater( + dio.get('https://localhost:${server.port}/'), + throwsA( + allOf([ + isA(), + (DioException e) => e.type == DioExceptionType.badCertificate, + ]), + ), + ); + } finally { + await server.close(force: true); + } + }); + + group('through a CONNECT proxy (post-response validation):', () { + late HttpServer secureBackend; + late ServerSocket proxy; + late _StubProxy proxyState; + + setUp(() async { + final ctx = SecurityContext() + ..useCertificateChain('test/_pinning/server_cert.pem') + ..usePrivateKey('test/_pinning/server_key.pem'); + secureBackend = await HttpServer.bindSecure('localhost', 0, ctx); + secureBackend.listen((req) async { + req.response.statusCode = 200; + req.response.write('ok'); + await req.response.close(); + }); + proxyState = _StubProxy(); + proxy = await proxyState.bind('localhost'); + }); + + tearDown(() async { + await proxy.close(); + await secureBackend.close(force: true); + }); + + test('validateCertificate runs (post-response) through proxy', + () async { + bool validatorCalled = false; + final adapter = IOHttpClientAdapter( + validateCertificate: (cert, host, port) { + validatorCalled = true; + expect(host, 'localhost'); + expect(port, secureBackend.port); + return true; + }, + // ignore: deprecated_member_use_from_same_package + onHttpClientCreate: (client) { + client.badCertificateCallback = (cert, host, port) => true; + client.findProxy = (uri) => 'PROXY localhost:${proxy.port}'; + return client; + }, + ); + final dio = Dio()..httpClientAdapter = adapter; + final res = await dio.get( + 'https://localhost:${secureBackend.port}/', + options: Options(responseType: ResponseType.plain), + ); + expect(validatorCalled, isTrue); + expect(res.statusCode, 200); + expect(res.data, 'ok'); + expect(proxyState.connectCount, 1); + }); + + test('rejection through proxy raises badCertificate ' + '(post-response, not pre-emission)', () async { + final adapter = IOHttpClientAdapter( + validateCertificate: (cert, host, port) => false, + // ignore: deprecated_member_use_from_same_package + onHttpClientCreate: (client) { + client.badCertificateCallback = (cert, host, port) => true; + client.findProxy = (uri) => 'PROXY localhost:${proxy.port}'; + return client; + }, + ); + final dio = Dio()..httpClientAdapter = adapter; + await expectLater( + dio.post( + 'https://localhost:${secureBackend.port}/leak', + data: 'SENSITIVE-PAYLOAD', + ), + throwsA( + allOf([ + isA(), + (DioException e) => e.type == DioExceptionType.badCertificate, + ]), + ), + ); + // The proxy was reached, the tunnel was established, the request + // was forwarded to the backend (post-response path leaks the body + // — documented limitation of the proxy path). + expect(proxyState.connectCount, 1); + }); + }); }); } + +/// Stub HTTP/1.1 CONNECT proxy for tests. Accepts `CONNECT host:port` and +/// opens a TCP tunnel to the target, forwarding bytes both directions. +class _StubProxy { + int connectCount = 0; + + Future bind(String address) async { + final server = await ServerSocket.bind(address, 0); + server.listen(_handleClient); + return server; + } + + void _handleClient(Socket client) { + final headerBuffer = BytesBuilder(copy: false); + Socket? backend; + bool tunneled = false; + + client.listen( + (chunk) async { + if (tunneled) { + backend?.add(chunk); + return; + } + headerBuffer.add(chunk); + final decoded = + ascii.decode(headerBuffer.toBytes(), allowInvalid: true); + final end = decoded.indexOf('\r\n\r\n'); + if (end < 0) { + return; + } + final requestLine = decoded.substring(0, decoded.indexOf('\r\n')); + final match = RegExp(r'^CONNECT (\S+):(\d+)').firstMatch(requestLine); + if (match == null) { + client.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + await client.close(); + return; + } + connectCount++; + try { + backend = + await Socket.connect(match.group(1)!, int.parse(match.group(2)!)); + } catch (_) { + client.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + await client.close(); + return; + } + client.write('HTTP/1.1 200 Connection established\r\n\r\n'); + tunneled = true; + backend!.listen( + client.add, + onError: (_) => client.destroy(), + onDone: () => client.destroy(), + ); + }, + onError: (_) => backend?.destroy(), + onDone: () => backend?.destroy(), + ); + } +} From 8a66e1ebd3b907cd9569a396e4d9efaa5ef8c8db Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Mon, 11 May 2026 16:13:54 +0500 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20Address=20PR=20#2512=20revie?= =?UTF-8?q?w:=20keep=20min=20SDK=20at=202.18,=20drop=20fromSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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` is implicitly assignable to `ConnectionTask` 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 #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. --- dio/CHANGELOG.md | 4 --- dio/lib/src/adapters/io_adapter.dart | 49 ++++++++++++++++------------ dio/lib/src/dio_mixin.dart | 3 +- dio/pubspec.yaml | 2 +- dio_test/pubspec.yaml | 2 +- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 833e629ac..b17bd245a 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -27,10 +27,6 @@ See the [Migration Guide][] for the complete breaking changes list.** 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 diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart index 267a9014b..dc35b37cc 100644 --- a/dio/lib/src/adapters/io_adapter.dart +++ b/dio/lib/src/adapters/io_adapter.dart @@ -361,26 +361,35 @@ class IOHttpClientAdapter implements HttpClientAdapter { // is honored on every new connection. final validator = validateCertificate; - 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) { - final cert = ss.peerCertificate; - if (!validator(cert, url.host, url.port)) { - ss.destroy(); - throw _BadCertificateException(url.host, url.port, cert); - } + // [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] is implicitly assignable to + // [ConnectionTask] 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 ss; - }(); - return ConnectionTask.fromSocket(socketFuture, () {}); + } + return task; } } diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index 6f2483687..7145cf0cd 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -24,7 +24,8 @@ import 'transformer.dart'; part 'interceptor.dart'; -abstract mixin class DioMixin implements Dio { +// TODO(EVERYONE): Use `mixin class` when the lower bound of SDK is raised to 3.0.0. +abstract class DioMixin implements Dio { /// The base request config for the instance. @override late BaseOptions options; diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml index 59b2ae30a..9fbb9af78 100644 --- a/dio/pubspec.yaml +++ b/dio/pubspec.yaml @@ -17,7 +17,7 @@ repository: https://github.com/cfug/dio/blob/main/dio issue_tracker: https://github.com/cfug/dio/issues environment: - sdk: '>=3.5.0 <4.0.0' + sdk: '>=2.18.0 <4.0.0' dependencies: async: ^2.8.2 diff --git a/dio_test/pubspec.yaml b/dio_test/pubspec.yaml index 138c8c2b0..3033b41a9 100644 --- a/dio_test/pubspec.yaml +++ b/dio_test/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/cfug/dio/blob/main/dio_test issue_tracker: https://github.com/cfug/dio/issues environment: - sdk: '>=3.5.0 <4.0.0' + sdk: '>=2.18.0 <4.0.0' dependencies: dio: any From a7372d3652dbb7368c26eb01689f2d2cd59e03a3 Mon Sep 17 00:00:00 2001 From: Meylis Annagurbanov Date: Sun, 28 Jun 2026 13:22:53 +0500 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20Address=20PR=20#2512=20revie?= =?UTF-8?q?w:=20green=20CI=20+=20don't=20commit=20test=20certs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Format io_adapter.dart, pinning_test.dart, stacktrace_test.dart (dart format --set-exit-if-changed was failing on the stable job). - Fix the failing '2 requests' test: the post-response block still fires per-request, so a pooled 2-request flow invokes the validator 3 times (1 pre-emission + 2 post-response), not 1. Replace the flaky network test with a deterministic local-server test asserting 3. - Generate the localhost test keypair at runtime via openssl in setUpAll (temp dir, cleaned up in tearDownAll) instead of committing server_cert.pem / server_key.pem. No private key in the repo. --- dio/lib/src/adapters/io_adapter.dart | 3 +- dio/test/_pinning/README.md | 21 ----- dio/test/_pinning/server_cert.pem | 19 ---- dio/test/_pinning/server_key.pem | 28 ------ dio/test/pinning_test.dart | 129 ++++++++++++++++++--------- dio/test/stacktrace_test.dart | 5 +- 6 files changed, 89 insertions(+), 116 deletions(-) delete mode 100644 dio/test/_pinning/README.md delete mode 100644 dio/test/_pinning/server_cert.pem delete mode 100644 dio/test/_pinning/server_key.pem diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart index dc35b37cc..69c4c1c48 100644 --- a/dio/lib/src/adapters/io_adapter.dart +++ b/dio/lib/src/adapters/io_adapter.dart @@ -45,8 +45,7 @@ class _BadCertificateException implements HandshakeException { OSError? get osError => null; @override - String toString() => - 'HandshakeException: $message (host=$host, port=$port)'; + String toString() => 'HandshakeException: $message (host=$host, port=$port)'; } /// Creates an [IOHttpClientAdapter]. diff --git a/dio/test/_pinning/README.md b/dio/test/_pinning/README.md deleted file mode 100644 index 059c9d041..000000000 --- a/dio/test/_pinning/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Test fixtures: pinning - -`server_cert.pem` and `server_key.pem` are a self-signed TLS keypair used by -`pinning_test.dart`'s pre-emission validation tests to spin up local -`HttpServer.bindSecure` instances on `localhost`. - -These files are committed to keep the test suite hermetic (no network -dependency for the load-bearing regression test that asserts request bytes -do not leak when `validateCertificate` rejects). - -The current pair has 10-year validity. To regenerate when it expires: - -```sh -openssl req -x509 -newkey rsa:2048 \ - -keyout server_key.pem -out server_cert.pem \ - -days 3650 -nodes \ - -subj "/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" -``` - -The private key is for localhost-only testing and has no production value. diff --git a/dio/test/_pinning/server_cert.pem b/dio/test/_pinning/server_cert.pem deleted file mode 100644 index 4936a55ad..000000000 --- a/dio/test/_pinning/server_cert.pem +++ /dev/null @@ -1,19 +0,0 @@ ------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----- diff --git a/dio/test/_pinning/server_key.pem b/dio/test/_pinning/server_key.pem deleted file mode 100644 index 55afd23a4..000000000 --- a/dio/test/_pinning/server_key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------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----- diff --git a/dio/test/pinning_test.dart b/dio/test/pinning_test.dart index c9f6659ae..bd9ba6977 100644 --- a/dio/test/pinning_test.dart +++ b/dio/test/pinning_test.dart @@ -24,6 +24,49 @@ void main() { } group('pinning:', () { + // A throwaway self-signed localhost keypair is generated at runtime so + // that no private key is committed to the repository. openssl is already + // a requirement for scripts/prepare_pinning_certs.sh (used by the tls + // network tests in this group), so it is a safe build dependency to lean + // on here. + late Directory certDir; + late String certPath; + late String keyPath; + + setUpAll(() { + certDir = Directory.systemTemp.createTempSync('dio_pinning_test_'); + certPath = '${certDir.path}/server_cert.pem'; + keyPath = '${certDir.path}/server_key.pem'; + final result = Process.runSync('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '3650', + '-nodes', + '-subj', + '/CN=localhost', + ]); + if (result.exitCode != 0) { + throw StateError( + 'Failed to generate a self-signed test certificate with openssl ' + '(exit ${result.exitCode}). openssl must be installed to run the ' + 'pinning tests.\n${result.stderr}', + ); + } + }); + + tearDownAll(() { + if (certDir.existsSync()) { + certDir.deleteSync(recursive: true); + } + }); + test('trusted host allowed with no approver', () async { await Dio().get(trustedCertUrl); }); @@ -154,37 +197,6 @@ void main() { tags: ['tls'], ); - test( - '2 requests share connection (1 approval)', - () async { - // Validation is now per TCP connection (not per HTTP request) since - // the callback fires from the connectionFactory pre-emission rather - // than post-response. HttpClient pools connections, so back-to-back - // requests within idleTimeout (3s) share one connection and one - // approval. Matches dio_http2_adapter's long-standing semantics. - int approvalCount = 0; - final dio = Dio(); - dio.httpClientAdapter = IOHttpClientAdapter( - validateCertificate: (cert, host, port) { - approvalCount++; - return fingerprint() == sha256.convert(cert!.der).toString(); - }, - ); - Response response = await dio.get( - trustedCertUrl, - options: Options(validateStatus: (status) => true), - ); - expect(response.data, isNotNull); - response = await dio.get( - trustedCertUrl, - options: Options(validateStatus: (status) => true), - ); - expect(response.data, isNotNull); - expect(approvalCount, 1); - }, - tags: ['tls'], - ); - group('pre-emission validation:', () { late HttpServer secureServer; late int bytesReceived; @@ -194,8 +206,8 @@ void main() { bytesReceived = 0; requestsHandled = 0; final ctx = SecurityContext() - ..useCertificateChain('test/_pinning/server_cert.pem') - ..usePrivateKey('test/_pinning/server_key.pem'); + ..useCertificateChain(certPath) + ..usePrivateKey(keyPath); secureServer = await HttpServer.bindSecure('localhost', 0, ctx); secureServer.listen((req) async { requestsHandled++; @@ -268,8 +280,8 @@ void main() { // behavior). final dio = Dio(); dio.httpClientAdapter = IOHttpClientAdapter( - createHttpClient: () => HttpClient() - ..badCertificateCallback = (cert, host, port) => true, + createHttpClient: () => + HttpClient()..badCertificateCallback = (cert, host, port) => true, validateCertificate: (cert, host, port) => false, ); DioException? error; @@ -288,6 +300,36 @@ void main() { expect(requestsHandled, 1); expect(bytesReceived, greaterThan(0)); }); + + test('two pooled requests: 1 pre-emission + 2 post-response validations', + () async { + // Two back-to-back requests within idleTimeout (3s) share one pooled + // TCP connection, so the connectionFactory pre-emission hook runs + // once (per connection) while the post-response block runs once per + // request. The validator is therefore invoked 3 times for 2 requests. + // This replaces the former network-dependent '2 requests' test with a + // deterministic local-server assertion. + int approvals = 0; + final dio = Dio(); + dio.httpClientAdapter = IOHttpClientAdapter( + validateCertificate: (cert, host, port) { + approvals++; + return true; + }, + ); + await dio.post( + 'https://localhost:${secureServer.port}/one', + data: 'a', + options: Options(responseType: ResponseType.plain), + ); + await dio.post( + 'https://localhost:${secureServer.port}/two', + data: 'b', + options: Options(responseType: ResponseType.plain), + ); + expect(approvals, 3); + expect(requestsHandled, 2); + }); }); test('plain http skips pre-emission validation', () async { @@ -325,15 +367,16 @@ void main() { } }); - test('validateCertificate set after construction still fires (legacy ' + test( + 'validateCertificate set after construction still fires (legacy ' 'post-response path)', () async { // Late-mutation regression guard: a user who constructs the adapter // without validateCertificate and sets it later should still have the // callback fire (via the legacy post-response path), even though the // pre-emission factory could not be installed. final ctx = SecurityContext() - ..useCertificateChain('test/_pinning/server_cert.pem') - ..usePrivateKey('test/_pinning/server_key.pem'); + ..useCertificateChain(certPath) + ..usePrivateKey(keyPath); final server = await HttpServer.bindSecure('localhost', 0, ctx); server.listen((req) { req.response.statusCode = 200; @@ -376,8 +419,8 @@ void main() { setUp(() async { final ctx = SecurityContext() - ..useCertificateChain('test/_pinning/server_cert.pem') - ..usePrivateKey('test/_pinning/server_key.pem'); + ..useCertificateChain(certPath) + ..usePrivateKey(keyPath); secureBackend = await HttpServer.bindSecure('localhost', 0, ctx); secureBackend.listen((req) async { req.response.statusCode = 200; @@ -393,8 +436,7 @@ void main() { await secureBackend.close(force: true); }); - test('validateCertificate runs (post-response) through proxy', - () async { + test('validateCertificate runs (post-response) through proxy', () async { bool validatorCalled = false; final adapter = IOHttpClientAdapter( validateCertificate: (cert, host, port) { @@ -421,7 +463,8 @@ void main() { expect(proxyState.connectCount, 1); }); - test('rejection through proxy raises badCertificate ' + test( + 'rejection through proxy raises badCertificate ' '(post-response, not pre-emission)', () async { final adapter = IOHttpClientAdapter( validateCertificate: (cert, host, port) => false, diff --git a/dio/test/stacktrace_test.dart b/dio/test/stacktrace_test.dart index 4daf8bb21..0ac464d27 100644 --- a/dio/test/stacktrace_test.dart +++ b/dio/test/stacktrace_test.dart @@ -210,9 +210,8 @@ void main() async { allOf([ isA(), (DioException e) => e.type == DioExceptionType.badCertificate, - (DioException e) => e.stackTrace - .toString() - .contains('test/stacktrace_test.dart'), + (DioException e) => + e.stackTrace.toString().contains('test/stacktrace_test.dart'), ]), ), );