diff --git a/.github/workflows/dio.yml b/.github/workflows/dio.yml index fda409c19..991e0206c 100644 --- a/.github/workflows/dio.yml +++ b/.github/workflows/dio.yml @@ -46,22 +46,17 @@ jobs: matrix: sdk: [ stable, beta ] platform: [ vm, chrome ] - defaults: - run: - working-directory: dio steps: - uses: actions/checkout@v3 - uses: dart-lang/setup-dart@v1 with: sdk: ${{ matrix.sdk }} - - run: dart pub get - - run: dart test --chain-stack-traces --platform=${{ matrix.platform }} - - name: Upload test report - uses: actions/upload-artifact@v3 - if: always() - with: - name: test-results-${{ matrix.sdk }}-${{ matrix.platform }} - path: dio/build/reports/test-results.json + - run: | + chmod +x ./scripts/prepare_pinning_certs.sh + ./scripts/prepare_pinning_certs.sh + shell: bash + - run: cd dio && dart pub get + - run: cd dio && dart test --chain-stack-traces --platform=${{ matrix.platform }} publish-dry-run: name: Publish dry-run diff --git a/.github/workflows/plugin_http2_adapter.yml b/.github/workflows/plugin_http2_adapter.yml index d162f6aad..08c765f5f 100644 --- a/.github/workflows/plugin_http2_adapter.yml +++ b/.github/workflows/plugin_http2_adapter.yml @@ -45,19 +45,13 @@ jobs: fail-fast: false matrix: sdk: [ stable, beta ] - defaults: - run: - working-directory: plugins/http2_adapter steps: - uses: actions/checkout@v3 - uses: dart-lang/setup-dart@v1 with: sdk: ${{ matrix.sdk }} - - run: dart pub get - - run: dart test --chain-stack-traces - - name: Upload test report - uses: actions/upload-artifact@v3 - if: always() - with: - name: test-results-${{ matrix.sdk }} - path: plugins/http2_adapter/build/reports/test-results.json + - run: | + chmod +x ./scripts/prepare_pinning_certs.sh + ./scripts/prepare_pinning_certs.sh + - run: cd plugins/http2_adapter && dart pub get + - run: cd plugins/http2_adapter && dart test --chain-stack-traces diff --git a/.gitignore b/.gitignore index 1e1fa517b..c1a8bd623 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dio/.idea/ dio/.exampl dio/coverage dio/test/_download_test.md +dio/test/*_pinning.txt # plugins plugins/cookie_manager/.packages @@ -32,6 +33,7 @@ plugins/http2_adapter/.dart_tool/ plugins/http2_adapter/.pub/ plugins/http2_adapter/.idea/ plugins/http2_adapter/.exampl +plugins/http2_adapter/test/*_pinning.txt .vscode/ diff --git a/README.md b/README.md index 72894313c..1fa7231f6 100644 --- a/README.md +++ b/README.md @@ -686,13 +686,54 @@ There is a complete example [here](https://github.com/flutterchina/dio/blob/mast ### Https certificate verification -There are two ways to verify the https certificate. Suppose the certificate format is PEM, the code like: +HTTPS certificate verification (or public key pinning) refers to the process of ensuring that the certificates protecting the TLS connection to the server are the ones you expect them to be. The intention is to reduce the chance of a man-in-the-middle attack. The theory is covered by [OWASP](https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning). + +_Server Response Certificate_ + +Unlike other methods, this one works with the certificate of the server itself. + +```dart +String fingerprint = 'ee5ce1dfa7a53657c545c62b65802e4272878dabd65c0aadcf85783ebb0b4d5c'; +(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = + (_) { + // Don't trust any certificate just because their root cert is trusted + 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; + }; +// Check that the cert fingerprint matches the one we expect +(dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate = + (cert, host, port) { + // We definitely require _some_ certificate + if (cert == null) return false; + // Validate it any way you want. Here we only check that + // the fingerprint matches the OpenSSL SHA256. + return fingerprint == sha256.convert(cert.der).toString(); + }; +``` + +You can use openssl to read the SHA256 value of a certificate: + +```sh +openssl s_client -servername pinning-test.badssl.com -connect pinning-test.badssl.com:443 < /dev/null 2>/dev/null \ + | openssl x509 -noout -fingerprint -sha256 + +# SHA256 Fingerprint=EE:5C:E1:DF:A7:A5:36:57:C5:45:C6:2B:65:80:2E:42:72:87:8D:AB:D6:5C:0A:AD:CF:85:78:3E:BB:0B:4D:5C +# (remove the formatting, keep only lower case hex characters to match the `sha256` above) +``` + +_Certificate Authority Verification_ + +These methods work well when your server has a self-signed certificate, but they don't work for certificates issued by a 3rd party like AWS or Let's Encrypt. + +There are two ways to verify the root of the https certificate chain provided by the server. Suppose the certificate format is PEM, the code like: ```dart -String PEM='XXXXX'; // certificate content +String PEM='XXXXX'; // root certificate content (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) { client.badCertificateCallback=(X509Certificate cert, String host, int port){ - if(cert.pem==PEM){ // Verify the certificate + if(cert.pem==PEM){ // Verify the root certificate return true; } return false; @@ -705,14 +746,14 @@ Another way is creating a `SecurityContext` when create the `HttpClient`: ```dart (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) { SecurityContext sc = SecurityContext(); - //file is the path of certificate + //file is the path of root certificate sc.setTrustedCertificates(file); HttpClient httpClient = HttpClient(context: sc); return httpClient; }; ``` -In this way, the format of certificate must be PEM or PKCS12. +In this way, the format of certificate must be PEM or PKCS12. ## Http2 support diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index cc91d1cb8..552e68ecd 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -1,6 +1,7 @@ # 5.0.0-dev.1 - A platform independend `HttpClientAdapter` can now be instantiated by doing `dio.httpClientAdapter = HttpClientAdapter();`. +- Add `ValidateCertificate` to handle certificate pinning better. ## Breaking Changes diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart index f0d841a33..1f0fd2da9 100644 --- a/dio/lib/src/adapters/io_adapter.dart +++ b/dio/lib/src/adapters/io_adapter.dart @@ -6,10 +6,12 @@ import '../options.dart'; import '../dio_error.dart'; import '../redirect_record.dart'; -@Deprecated('Use IOHttpClientAdapter instead') +@Deprecated('Use IOHttpClientAdapter instead. This will be removed in 6.0.0') typedef DefaultHttpClientAdapter = IOHttpClientAdapter; typedef OnHttpClientCreate = HttpClient? Function(HttpClient client); +typedef ValidateCertificate = bool Function( + X509Certificate? certificate, String host, int port); HttpClientAdapter createAdapter() => IOHttpClientAdapter(); @@ -20,6 +22,14 @@ class IOHttpClientAdapter implements HttpClientAdapter { /// it when a HttpClient created. OnHttpClientCreate? onHttpClientCreate; + /// 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. + ValidateCertificate? validateCertificate; + HttpClient? _defaultHttpClient; bool _closed = false; @@ -113,6 +123,21 @@ class IOHttpClientAdapter implements HttpClientAdapter { final responseStream = await future; + if (validateCertificate != null) { + final host = options.uri.host; + final port = options.uri.port; + final isCertApproved = + validateCertificate!(responseStream.certificate, host, port); + if (!isCertApproved) { + throw DioError( + requestOptions: options, + type: DioErrorType.badCertificate, + error: responseStream.certificate, + message: 'The certificate of the response is not approved.', + ); + } + } + var stream = responseStream.transform(StreamTransformer.fromHandlers( handleData: (data, sink) { diff --git a/dio/lib/src/dio_error.dart b/dio/lib/src/dio_error.dart index 888162553..135f25c88 100644 --- a/dio/lib/src/dio_error.dart +++ b/dio/lib/src/dio_error.dart @@ -11,6 +11,9 @@ enum DioErrorType { ///It occurs when receiving timeout. receiveTimeout, + /// Caused by an incorrect certificate as configured by [ValidateCertificate]. + badCertificate, + /// The [DioError] was caused by an incorrect status code as configured by /// [ValidateStatus]. badResponse, @@ -35,6 +38,8 @@ extension _DioErrorTypeExtension on DioErrorType { return 'send timeout'; case DioErrorType.receiveTimeout: return 'receive timeout'; + case DioErrorType.badCertificate: + return 'bad certificate'; case DioErrorType.badResponse: return 'bad response'; case DioErrorType.cancel: diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml index 84627cf0a..3cdc2ab70 100644 --- a/dio/pubspec.yaml +++ b/dio/pubspec.yaml @@ -13,4 +13,5 @@ dependencies: dev_dependencies: lints: ^1.0.1 test: ^1.5.1 - coverage: ^1.0.3 \ No newline at end of file + coverage: ^1.0.3 + crypto: ^3.0.2 diff --git a/dio/test/exception_test.dart b/dio/test/exception_test.dart index 640e5365f..6499c57c9 100644 --- a/dio/test/exception_test.dart +++ b/dio/test/exception_test.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:dio/dio.dart'; import 'package:test/test.dart'; import 'package:dio/io.dart'; @@ -48,9 +47,8 @@ void main() { test('allow badssl', () async { var dio = Dio(); (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = - (HttpClient client) { - client.badCertificateCallback = - (X509Certificate cert, String host, int port) => true; + (client) { + return client..badCertificateCallback = (cert, host, port) => true; }; var response = await dio.get('https://wrong.host.badssl.com/'); expect(response.statusCode, 200); diff --git a/dio/test/pinning_test.dart b/dio/test/pinning_test.dart new file mode 100644 index 000000000..0d08a72ca --- /dev/null +++ b/dio/test/pinning_test.dart @@ -0,0 +1,144 @@ +@TestOn('vm') +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:test/test.dart'; +import 'package:dio/io.dart'; + +void main() { + // NOTE: Run test.sh to download the currrent certs to the file below. + final trustedCertUrl = 'https://sha256.badssl.com/'; + final untrustedCertUrl = 'https://wrong.host.badssl.com/'; + + // OpenSSL output like: SHA256 Fingerprint=EE:5C:E1:DF:A7:A4... + // All badssl.com hosts have the same cert, they just have TLS + // setting or other differences (like host name) that make them bad. + final lines = File('test/_pinning.txt').readAsLinesSync(); + final fingerprint = + lines.first.split('=').last.toLowerCase().replaceAll(':', ''); + + test('pinning: trusted host allowed with no approver', () async { + await Dio().get(trustedCertUrl); + }); + + test('pinning: untrusted host rejected with no approver', () async { + dynamic error; + + try { + var dio = Dio(); + await dio.get(untrustedCertUrl); + fail('did not throw'); + } on DioError catch (e) { + error = e; + } + expect(error, isNotNull); + expect(error is Exception, isTrue); + }); + + test('pinning: every certificate tested and rejected', () async { + dynamic error; + + try { + var dio = Dio(); + (dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate = + (certificate, host, port) => false; + await dio.get(trustedCertUrl); + fail('did not throw'); + } on DioError catch (e) { + error = e; + } + expect(error, isNotNull); + expect(error is Exception, isTrue); + }); + + test('pinning: trusted certificate tested and allowed', () async { + var dio = Dio(); + // badCertificateCallback never called for trusted certificate + (dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate = + (cert, host, port) => + fingerprint == sha256.convert(cert!.der).toString(); + final response = await dio.get(trustedCertUrl, + options: Options(validateStatus: (status) => true)); + expect(response, isNotNull); + }); + + test('pinning: untrusted certificate tested and allowed', () async { + var dio = Dio(); + // badCertificateCallback must allow the untrusted certificate through + (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = + (client) { + return client..badCertificateCallback = (cert, host, port) => true; + }; + (dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate = + (cert, host, port) => + fingerprint == sha256.convert(cert!.der).toString(); + final response = await dio.get(untrustedCertUrl, + options: Options(validateStatus: (status) => true)); + expect(response, isNotNull); + }); + + test('pinning: untrusted certificate rejected before validateCertificate', + () async { + dynamic error; + + try { + var dio = Dio(); + (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = + (_) => HttpClient(context: SecurityContext(withTrustedRoots: false)); + (dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate = + (cert, host, port) => fail('Should not be evaluated'); + await dio.get(untrustedCertUrl, + options: Options(validateStatus: (status) => true)); + fail('did not throw'); + } on DioError catch (e) { + error = e; + } + expect(error, isNotNull); + expect(error is Exception, isTrue); + }); + + test('bad pinning: badCertCallback does not use leaf certificate', () async { + dynamic error; + + try { + var dio = Dio(); + (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = + (HttpClient client) { + final effectiveClient = + HttpClient(context: SecurityContext(withTrustedRoots: false)); + // Comparison fails because fingerprint is for leaf cert, but + // this cert is from Let's Encrypt. + effectiveClient.badCertificateCallback = + (X509Certificate cert, String host, int port) => + fingerprint == sha256.convert(cert.der).toString(); + return effectiveClient; + }; + await dio.get(trustedCertUrl, + options: Options(validateStatus: (status) => true)); + fail('did not throw'); + } on DioError catch (e) { + error = e; + } + expect(error, isNotNull); + expect(error is Exception, isTrue); + }); + + test('pinning: 2 requests == 2 approvals', () async { + int approvalCount = 0; + var dio = Dio(); + // badCertificateCallback never called for trusted certificate + (dio.httpClientAdapter as DefaultHttpClientAdapter).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, 2); + }); +} diff --git a/example/lib/certificate_pinning.dart b/example/lib/certificate_pinning.dart new file mode 100644 index 000000000..0b353a4c5 --- /dev/null +++ b/example/lib/certificate_pinning.dart @@ -0,0 +1,61 @@ +import 'package:crypto/crypto.dart'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; + +void main() async { + var dio = Dio(); + + // TODO: always update to the latest fingerprint. + // openssl s_client -servername pinning-test.badssl.com \ + // -connect pinning-test.badssl.com:443 < /dev/null 2>/dev/null \ + // | openssl x509 -noout -fingerprint -sha256 + String fingerprint = + // 'update-with-latest-sha256-hex-ee5ce1dfa7a53657c545c62b65802e4272'; + // should look like this: + 'ee5ce1dfa7a53657c545c62b65802e4272878dabd65c0aadcf85783ebb0b4d5c'; + + // Don't trust any certificate just because their root cert is trusted + (dio.httpClientAdapter as IOHttpClientAdapter).onHttpClientCreate = (_) { + 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; + }; + + // Check that the cert fingerprint matches the one we expect + (dio.httpClientAdapter as IOHttpClientAdapter).validateCertificate = + (cert, host, port) { + // We definitely require _some_ certificate + if (cert == null) return false; + // Validate it any way you want. Here we only check that + // the fingerprint matches the OpenSSL SHA256. + final f = sha256.convert(cert.der).toString(); + print(f); + return fingerprint == f; + }; + + Response? response; + + // Normally this certificate would normally be accepted, but all + // certs are refused initially, and it is still checked. + response = await dio.get('https://sha256.badssl.com/'); + print(response.data); + response = null; + + // Normally this certificate would be rejected because its host isn't covered in the certificate. + response = await dio.get('https://wrong.host.badssl.com/'); + print(response.data); + response = null; + + try { + // This certificate doesn't have the same fingerprint. + response = await dio.get('https://bad.host.badssl.com/'); + print(response.data); + } on DioError catch (e) { + print(e.message); + print(response?.data); + dio.close(force: true); + } +} diff --git a/plugins/http2_adapter/CHANGELOG.md b/plugins/http2_adapter/CHANGELOG.md index 10ea7bfaa..0a9b5cf3f 100644 --- a/plugins/http2_adapter/CHANGELOG.md +++ b/plugins/http2_adapter/CHANGELOG.md @@ -1,3 +1,6 @@ +## NEXT + +* Add `validateCertificate` for `ClientSetting`. ## [2.0.0] diff --git a/plugins/http2_adapter/lib/src/client_setting.dart b/plugins/http2_adapter/lib/src/client_setting.dart index 7c9c1467f..f4e83d9ea 100644 --- a/plugins/http2_adapter/lib/src/client_setting.dart +++ b/plugins/http2_adapter/lib/src/client_setting.dart @@ -1,5 +1,8 @@ part of 'http2_adapter.dart'; +typedef ValidateCertificate = bool Function( + X509Certificate? certificate, String host, int port); + class ClientSetting { /// The certificate provided by the server is checked /// using the trusted certificates set in the SecurityContext object. @@ -13,4 +16,12 @@ class ClientSetting { /// the connection or not. The handler should return true /// to continue the [SecureSocket] connection. bool Function(X509Certificate certificate)? onBadCertificate; + + /// 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. + ValidateCertificate? validateCertificate; } diff --git a/plugins/http2_adapter/lib/src/connection_manager_imp.dart b/plugins/http2_adapter/lib/src/connection_manager_imp.dart index 82b2b56aa..65c0e9ef5 100644 --- a/plugins/http2_adapter/lib/src/connection_manager_imp.dart +++ b/plugins/http2_adapter/lib/src/connection_manager_imp.dart @@ -87,6 +87,20 @@ class _ConnectionManager implements ConnectionManager { } rethrow; } + + if (clientConfig.validateCertificate != null) { + final isCertApproved = clientConfig.validateCertificate!( + socket.peerCertificate, uri.host, uri.port); + if (!isCertApproved) { + throw DioError( + requestOptions: options, + type: DioErrorType.badCertificate, + error: socket.peerCertificate, + message: 'The certificate of the response is not approved.', + ); + } + } + // Config a ClientTransportConnection and save it var transport = ClientTransportConnection.viaSocket(socket); var _transportState = _ClientTransportConnectionState(transport); diff --git a/plugins/http2_adapter/test/http2_test.dart b/plugins/http2_adapter/test/http2_test.dart index b6d7ba882..131bc2cba 100644 --- a/plugins/http2_adapter/test/http2_test.dart +++ b/plugins/http2_adapter/test/http2_test.dart @@ -1,8 +1,20 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:test/test.dart'; import 'package:dio_http2_adapter/dio_http2_adapter.dart'; void main() { + // NOTE: Run test.sh to download the currrent certs to the file below. + // + // OpenSSL output like: SHA256 Fingerprint=EE:5C:E1:DF:A7:A4... + // All badssl.com hosts have the same cert, they just have TLS + // setting or other differences (like host name) that make them bad. + final lines = File('test/_pinning_http2.txt').readAsLinesSync(); + final fingerprint = + lines.first.split('=').last.toLowerCase().replaceAll(':', ''); + test('adds one to input values', () async { var dio = Dio() ..options.baseUrl = 'https://www.ustc.edu.cn/' @@ -26,12 +38,166 @@ void main() { test('request with payload', () async { final dio = Dio() - ..options.baseUrl = 'https://postman-echo.com/' + ..options.baseUrl = 'https://httpbin.org/' ..httpClientAdapter = Http2Adapter(ConnectionManager( idleTimeout: Duration(milliseconds: 10), )); final res = await dio.post('post', data: 'TEST'); - assert(res.data['data'] == 'TEST'); + expect(res.data.toString(), contains('TEST')); + }); + + test('pinning: trusted host allowed with no approver', () async { + final dio = Dio() + ..httpClientAdapter = Http2Adapter(ConnectionManager( + idleTimeout: Duration(seconds: 10), + )); + + final res = await dio.get('https://httpbin.org/get'); + expect(res, isNotNull); + expect(res.data, isNotNull); + expect(res.data.toString(), contains('Host: httpbin.org')); + }); + + test('pinning: untrusted host rejected with no approver', () async { + dynamic error; + + try { + final dio = Dio() + ..httpClientAdapter = Http2Adapter(ConnectionManager( + idleTimeout: Duration(seconds: 10), + onClientCreate: (url, config) { + // Consider all hosts untrusted + config.context = SecurityContext(withTrustedRoots: false); + })); + + await dio.get('https://httpbin.org/get'); + fail('did not throw'); + } on DioError catch (e) { + error = e; + } + expect(error, isNotNull); + expect(error is Exception, isTrue); + }); + + test('pinning: trusted certificate tested and allowed', () async { + bool approved = false; + final dio = Dio() + ..httpClientAdapter = Http2Adapter(ConnectionManager( + idleTimeout: Duration(seconds: 10), + onClientCreate: (url, config) { + config.validateCertificate = (certificate, host, port) { + approved = true; + return true; + }; + }, + )); + + final res = await dio.get('https://httpbin.org/get'); + expect(approved, true); + expect(res, isNotNull); + expect(res.data, isNotNull); + expect(res.data.toString(), contains('Host: httpbin.org')); + }); + + test('pinning: untrusted certificate tested and allowed', () async { + bool badCert = false; + bool approved = false; + final dio = Dio() + ..httpClientAdapter = Http2Adapter(ConnectionManager( + idleTimeout: Duration(seconds: 10), + onClientCreate: (url, config) { + config.context = SecurityContext(withTrustedRoots: false); + config.onBadCertificate = (certificate) { + badCert = true; + return true; + }; + config.validateCertificate = (certificate, host, port) { + approved = true; + return true; + }; + }, + )); + + final res = await dio.get('https://httpbin.org/get'); + expect(badCert, true); + expect(approved, true); + expect(res, isNotNull); + expect(res.data, isNotNull); + expect(res.data.toString(), contains('Host: httpbin.org')); + }); + + test('pinning: untrusted certificate tested and allowed', () async { + bool badCert = false; + bool approved = false; + String? badCertSubject; + String? approverSubject; + String? badCertSha256; + String? approverSha256; + + final dio = Dio() + ..httpClientAdapter = Http2Adapter(ConnectionManager( + idleTimeout: Duration(seconds: 10), + onClientCreate: (url, config) { + config.context = SecurityContext(withTrustedRoots: false); + config.onBadCertificate = (certificate) { + badCert = true; + badCertSubject = certificate.subject.toString(); + badCertSha256 = sha256.convert(certificate.der).toString(); + return true; + }; + config.validateCertificate = (certificate, host, port) { + if (certificate == null) fail('must include a certificate'); + approved = true; + approverSubject = certificate.subject.toString(); + approverSha256 = sha256.convert(certificate.der).toString(); + return true; + }; + }, + )); + + final res = await dio.get('https://httpbin.org/get'); + expect(badCert, true); + expect(approved, true); + expect(badCertSubject, isNotNull); + expect(badCertSubject, isNot(contains('httpbin.org'))); + expect(badCertSha256, isNot(fingerprint)); + expect(approverSubject, isNotNull); + expect(approverSubject, contains('httpbin.org')); + expect(approverSha256, fingerprint); + expect(approverSubject, isNot(badCertSubject)); + expect(approverSha256, isNot(badCertSha256)); + expect(res, isNotNull); + expect(res.data, isNotNull); + expect(res.data.toString(), contains('Host: httpbin.org')); + }); + + test('pinning: 2 requests == 1 approval', () async { + int approvalCount = 0; + final dio = Dio() + ..options.baseUrl = 'https://httpbin.org/' + ..httpClientAdapter = Http2Adapter(ConnectionManager( + // allow connection reuse + idleTimeout: Duration(seconds: 20), + onClientCreate: (url, config) { + config.validateCertificate = (certificate, host, port) { + approvalCount++; + return true; + }; + }, + )); + + Response res = await dio.get('get'); + final firstTime = res.headers['date']; + expect(approvalCount, 1); + expect(res.data, isNotNull); + expect(res.data.toString(), contains('Host: httpbin.org')); + await Future.delayed(Duration(milliseconds: 900)); + res = await dio.get('get'); + final secondTime = res.headers['date']; + expect(approvalCount, 1); + expect(firstTime, isNot(secondTime)); + expect(res.data, isNotNull); + expect(res.data.toString(), contains('Host: httpbin.org')); }); } diff --git a/scripts/prepare_pinning_certs.sh b/scripts/prepare_pinning_certs.sh new file mode 100644 index 000000000..e105cc316 --- /dev/null +++ b/scripts/prepare_pinning_certs.sh @@ -0,0 +1,8 @@ +openssl s_client \ + -servername badssl.com \ + -connect badssl.com:443 < /dev/null 2>/dev/null \ + | openssl x509 -noout -fingerprint -sha256 > dio/test/_pinning.txt 2>/dev/null +openssl s_client \ + -servername httpbin.org \ + -connect httpbin.org:443 < /dev/null 2>/dev/null \ + | openssl x509 -noout -fingerprint -sha256 > plugins/http2_adapter/test/_pinning_http2.txt 2>/dev/null diff --git a/test.sh b/test.sh index a0a0c8e60..56342c517 100755 --- a/test.sh +++ b/test.sh @@ -1,4 +1,4 @@ -cd dio +cd dio || exit dart test --coverage=coverage . pub run coverage:format_coverage --packages=.packages -i coverage -o coverage/lcov.info --lcov genhtml -o coverage coverage/lcov.info