|
1 | 1 | import 'dart:async'; |
2 | | -import 'dart:convert'; |
3 | 2 | import 'dart:io'; |
4 | 3 | import 'dart:typed_data'; |
5 | 4 |
|
@@ -77,34 +76,45 @@ class IOHttpClientAdapter implements HttpClientAdapter { |
77 | 76 | /// Allows the user to decide if the leaf certificate of the TLS connection |
78 | 77 | /// is good. If this function is missing, then the certificate is allowed. |
79 | 78 | /// |
80 | | - /// When [createHttpClient] is **not** set, this callback fires **before** |
81 | | - /// the request body is sent, immediately after the TLS handshake completes. |
82 | | - /// Returning `false` aborts the connection without leaking any request |
83 | | - /// data and surfaces as [DioException.badCertificate]. This makes the |
84 | | - /// callback suitable for certificate or public-key pinning. |
| 79 | + /// For **direct HTTPS** connections (no proxy) with the default |
| 80 | + /// [createHttpClient], this callback fires **before** the request body |
| 81 | + /// is sent, immediately after the TLS handshake completes. Returning |
| 82 | + /// `false` aborts the connection without leaking any request data and |
| 83 | + /// surfaces as [DioException.badCertificate]. This makes the callback |
| 84 | + /// suitable for certificate or public-key pinning. |
85 | 85 | /// |
86 | | - /// When [createHttpClient] **is** supplied, dio cannot install the |
87 | | - /// pre-emission hook on the user-built [HttpClient]; the callback then |
88 | | - /// runs after the response head arrives (the legacy 5.x behavior). For |
89 | | - /// pre-emission validation in that case, set `connectionFactory` on the |
90 | | - /// [HttpClient] you return. |
| 86 | + /// The callback also runs **after the response head arrives** for the |
| 87 | + /// same connection. The two invocations receive the same leaf |
| 88 | + /// certificate, so for fingerprint-style pinning the second call is |
| 89 | + /// idempotent. If your callback has side effects (logging, metrics), |
| 90 | + /// expect to see them twice per request on the direct-HTTPS path. |
91 | 91 | /// |
92 | | - /// Validation runs once per TCP connection, not per request. Connections |
93 | | - /// are pooled by [HttpClient], so multiple requests to the same host |
94 | | - /// within `idleTimeout` will produce one approval call. |
| 92 | + /// **HTTPS through a proxy:** `HttpClient` performs its own `CONNECT` |
| 93 | + /// tunnel and TLS handshake, so the pre-emission hook is bypassed. |
| 94 | + /// Validation runs only post-response on this path — the request body |
| 95 | + /// has already been transmitted by the time the callback is invoked. |
| 96 | + /// For pre-emission pinning behind a proxy, use [createHttpClient] and |
| 97 | + /// install your own `connectionFactory` on the returned client. |
| 98 | + /// |
| 99 | + /// **Custom [createHttpClient]:** dio cannot install the pre-emission |
| 100 | + /// hook on a user-built [HttpClient]; the callback runs post-response |
| 101 | + /// (the legacy 5.x behavior). For pre-emission validation in that case, |
| 102 | + /// set `connectionFactory` on the [HttpClient] you return. |
95 | 103 | /// |
96 | 104 | /// On the pre-emission path, this callback is the **sole** gate for |
97 | 105 | /// certificate trust: system / CA validation is bypassed (the equivalent |
98 | 106 | /// of `HttpClient.badCertificateCallback: (_, _, _) => true` in the |
99 | 107 | /// existing example), so the callback receives every leaf cert and is |
100 | 108 | /// solely responsible for accepting or rejecting it. This matches what |
101 | 109 | /// users already configure manually for pinning and lets self-signed and |
102 | | - /// pinned-CA setups work without supplying [createHttpClient]. |
| 110 | + /// pinned-CA setups work without supplying [createHttpClient]. If you |
| 111 | + /// do not intend to perform pinning, do not set this callback — |
| 112 | + /// supplying any non-null value disables stdlib chain validation. |
103 | 113 | /// |
104 | 114 | /// Any [SecurityContext] you set on a custom [HttpClient] is **not** |
105 | 115 | /// used by the pre-emission path. To combine mTLS or a custom trust store |
106 | | - /// with pinning, supply a [createHttpClient] and pin in the legacy |
107 | | - /// post-response path. |
| 116 | + /// with pinning, supply a [createHttpClient] and pin in the post-response |
| 117 | + /// path. |
108 | 118 | ValidateCertificate? validateCertificate; |
109 | 119 |
|
110 | 120 | HttpClient? _cachedHttpClient; |
@@ -232,12 +242,19 @@ class IOHttpClientAdapter implements HttpClientAdapter { |
232 | 242 | } |
233 | 243 | final responseStream = await future; |
234 | 244 |
|
235 | | - if (validateCertificate != null && createHttpClient != null) { |
236 | | - // Legacy post-response validation: only runs when the user supplied |
237 | | - // a custom [createHttpClient]. In that case we cannot install the |
238 | | - // pre-emission [HttpClient.connectionFactory] without clobbering the |
239 | | - // user's client, so the callback runs after the response head has |
240 | | - // arrived. See the doc-comment on [validateCertificate]. |
| 245 | + if (validateCertificate != null) { |
| 246 | + // Post-response validation runs unconditionally as defense in depth. |
| 247 | + // On the pre-emission path it is redundant — the same cert was |
| 248 | + // already approved by the [HttpClient.connectionFactory] hook — but |
| 249 | + // it is the only line of defense for paths where pre-emission did |
| 250 | + // not run: (a) when the user supplied a custom [createHttpClient], |
| 251 | + // (b) when [validateCertificate] was set after the [HttpClient] was |
| 252 | + // first cached, or (c) for HTTPS through a proxy (see the doc-comment |
| 253 | + // on [validateCertificate]). For pinning the callback is idempotent |
| 254 | + // so the redundancy is harmless; callbacks with side effects will |
| 255 | + // observe one extra call per request on the direct-HTTPS path. |
| 256 | + // On plain HTTP, [responseStream.certificate] is null and the user |
| 257 | + // typically rejects nulls (matching the example). |
241 | 258 | final host = options.uri.host; |
242 | 259 | final port = options.uri.port; |
243 | 260 | final bool isCertApproved = validateCertificate!( |
@@ -314,132 +331,56 @@ class IOHttpClientAdapter implements HttpClientAdapter { |
314 | 331 | return onHttpClientCreate?.call(client) ?? client; |
315 | 332 | } |
316 | 333 |
|
317 | | - /// Custom connection factory that performs TLS handshake and runs |
| 334 | + /// Custom connection factory that performs the TLS handshake and runs |
318 | 335 | /// [validateCertificate] *before* yielding the socket to [HttpClient], |
319 | 336 | /// so a rejected certificate cannot leak request bytes. |
| 337 | + /// |
| 338 | + /// For HTTPS requests routed through a proxy, this factory cannot |
| 339 | + /// pre-emption-validate: HttpClient writes its own `CONNECT` and performs |
| 340 | + /// its own TLS handshake on top of the socket we return. In that case the |
| 341 | + /// proxy branch returns a plain socket to the proxy and validation falls |
| 342 | + /// back to the post-response check in [_fetch]. |
320 | 343 | Future<ConnectionTask<Socket>> _pinnedConnectionFactory( |
321 | 344 | Uri url, |
322 | 345 | String? proxyHost, |
323 | 346 | int? proxyPort, |
324 | 347 | ) async { |
325 | | - // Plain HTTP — no TLS handshake to gate. |
| 348 | + // Proxy: hand HttpClient a plain socket to the proxy and let it own the |
| 349 | + // CONNECT-tunnel and TLS upgrade. The post-response block in [_fetch] |
| 350 | + // performs validation in this path. |
| 351 | + if (proxyHost != null) { |
| 352 | + return Socket.startConnect(proxyHost, proxyPort!); |
| 353 | + } |
| 354 | + |
| 355 | + // Plain HTTP, no proxy: hand HttpClient the target socket. |
326 | 356 | if (url.scheme != 'https') { |
327 | | - if (proxyHost != null) { |
328 | | - return Socket.startConnect(proxyHost, proxyPort!); |
329 | | - } |
330 | 357 | return Socket.startConnect(url.host, url.port); |
331 | 358 | } |
332 | 359 |
|
333 | 360 | // Snapshot at call time so a runtime mutation of the public field |
334 | 361 | // is honored on every new connection. |
335 | 362 | final validator = validateCertificate; |
336 | 363 |
|
337 | | - if (proxyHost != null) { |
338 | | - return _connectHttpsViaProxy(url, proxyHost, proxyPort!, validator); |
339 | | - } |
340 | | - |
341 | 364 | final socketFuture = () async { |
342 | 365 | final ss = await SecureSocket.connect( |
343 | 366 | url.host, |
344 | 367 | url.port, |
345 | 368 | supportedProtocols: const ['http/1.1'], |
346 | | - // When [validateCertificate] is set, defer all certificate validation |
347 | | - // to the user's callback. This makes self-signed and pinned-CA setups |
348 | | - // work without forcing the user to also supply [createHttpClient]. |
| 369 | + // When [validateCertificate] is set, defer all certificate |
| 370 | + // validation to the user's callback. This makes self-signed and |
| 371 | + // pinned-CA setups work without forcing the user to also supply |
| 372 | + // [createHttpClient]. |
349 | 373 | onBadCertificate: validator == null ? null : (_) => true, |
350 | 374 | ); |
351 | 375 | if (validator != null) { |
352 | | - _validatePeerCertificate(ss, url.host, url.port, validator); |
| 376 | + final cert = ss.peerCertificate; |
| 377 | + if (!validator(cert, url.host, url.port)) { |
| 378 | + ss.destroy(); |
| 379 | + throw _BadCertificateException(url.host, url.port, cert); |
| 380 | + } |
353 | 381 | } |
354 | | - return ss as Socket; |
| 382 | + return ss; |
355 | 383 | }(); |
356 | 384 | return ConnectionTask.fromSocket(socketFuture, () {}); |
357 | 385 | } |
358 | | - |
359 | | - Future<ConnectionTask<Socket>> _connectHttpsViaProxy( |
360 | | - Uri target, |
361 | | - String proxyHost, |
362 | | - int proxyPort, |
363 | | - ValidateCertificate? validator, |
364 | | - ) async { |
365 | | - Socket? proxySocket; |
366 | | - bool cancelled = false; |
367 | | - |
368 | | - final socketFuture = () async { |
369 | | - proxySocket = await Socket.connect(proxyHost, proxyPort); |
370 | | - if (cancelled) { |
371 | | - proxySocket!.destroy(); |
372 | | - throw const SocketException('connection cancelled'); |
373 | | - } |
374 | | - |
375 | | - // HTTP/1.1 CONNECT tunnel preamble. |
376 | | - const crlf = '\r\n'; |
377 | | - final preamble = StringBuffer() |
378 | | - ..write('CONNECT ${target.host}:${target.port} HTTP/1.1$crlf') |
379 | | - ..write('Host: ${target.host}:${target.port}$crlf') |
380 | | - ..write(crlf); |
381 | | - proxySocket!.write(preamble.toString()); |
382 | | - |
383 | | - final completer = Completer<void>(); |
384 | | - late StreamSubscription<List<int>> subscription; |
385 | | - subscription = proxySocket!.listen( |
386 | | - (event) { |
387 | | - if (completer.isCompleted) { |
388 | | - return; |
389 | | - } |
390 | | - final response = ascii.decode(event); |
391 | | - final statusLine = response.split(crlf).first; |
392 | | - if (statusLine.contains(' 200 ')) { |
393 | | - completer.complete(); |
394 | | - } else { |
395 | | - completer.completeError( |
396 | | - SocketException( |
397 | | - 'Proxy CONNECT failed: $statusLine ' |
398 | | - '(host=${target.host}, port=${target.port})', |
399 | | - ), |
400 | | - ); |
401 | | - } |
402 | | - }, |
403 | | - onError: (Object e, StackTrace s) { |
404 | | - if (!completer.isCompleted) { |
405 | | - completer.completeError(e, s); |
406 | | - } |
407 | | - }, |
408 | | - ); |
409 | | - try { |
410 | | - await completer.future; |
411 | | - } finally { |
412 | | - await subscription.cancel(); |
413 | | - } |
414 | | - |
415 | | - final ss = await SecureSocket.secure( |
416 | | - proxySocket!, |
417 | | - host: target.host, |
418 | | - supportedProtocols: const ['http/1.1'], |
419 | | - onBadCertificate: validator == null ? null : (_) => true, |
420 | | - ); |
421 | | - if (validator != null) { |
422 | | - _validatePeerCertificate(ss, target.host, target.port, validator); |
423 | | - } |
424 | | - return ss as Socket; |
425 | | - }(); |
426 | | - |
427 | | - return ConnectionTask.fromSocket(socketFuture, () { |
428 | | - cancelled = true; |
429 | | - proxySocket?.destroy(); |
430 | | - }); |
431 | | - } |
432 | | - |
433 | | - void _validatePeerCertificate( |
434 | | - SecureSocket ss, |
435 | | - String host, |
436 | | - int port, |
437 | | - ValidateCertificate validator, |
438 | | - ) { |
439 | | - final cert = ss.peerCertificate; |
440 | | - if (!validator(cert, host, port)) { |
441 | | - ss.destroy(); |
442 | | - throw _BadCertificateException(host, port, cert); |
443 | | - } |
444 | | - } |
445 | 386 | } |
0 commit comments