From 0918227026be45ea2707a5fbe5c1023e74a37b7a Mon Sep 17 00:00:00 2001 From: Sylvester-git Date: Sun, 24 May 2026 13:30:19 +0100 Subject: [PATCH] feat(security): enhance security features and logging in Dio - Add CertificatePinner for fingerprint-based certificate pinning. - Implement maxResponseSize to prevent memory exhaustion from large responses. - Warn developers about missing timeouts in requests. - Improve LogInterceptor to redact sensitive headers in logs. - Update SECURITY.md with secure usage guidelines. --- .gitignore | 1 + SECURITY.md | 235 +++++++++- dio/lib/src/adapters/io_adapter.dart | 409 +++++++++++++----- dio/lib/src/dio_mixin.dart | 20 + dio/lib/src/form_data.dart | 7 +- dio/lib/src/interceptors/log.dart | 43 +- dio/lib/src/options.dart | 30 ++ .../src/response/response_stream_handler.dart | 17 + 8 files changed, 638 insertions(+), 124 deletions(-) diff --git a/.gitignore b/.gitignore index 61055ee9b..17007fd90 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ melos_overrides.yaml # FVM Version Cache .fvm/ .fvmrc +SECURITY_CONTRIBUTIONS.md diff --git a/SECURITY.md b/SECURITY.md index b1b6e845c..2182b7c1d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,10 +3,237 @@ ## Supported Versions | Version | Supported | -|---------|-----------| -| >=5.0 | ✅ | -| < 5.0 | ❌ | +| ------- | --------- | +| >=5.0 | ✅ | +| < 5.0 | ❌ | ## Reporting a Vulnerability -Contact cfug-team@googlegroups.com with your vulnerability report. +Contact with your vulnerability report. +Please do **not** open a public GitHub issue for security vulnerabilities. + +--- + +## Secure Usage Guide + +This section documents Dio's security-relevant behaviour, known limitations, +and recommended configuration for production applications. + +### 1. Always Set Timeouts + +All three timeouts default to `null` (no limit). A server that never sends +bytes will hold the connection open forever, exhausting resources and freezing +UI threads. + +```dart +final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), +); +``` + +Dio emits a developer warning (suppressed in release builds) when a request +is dispatched without `connectTimeout` or `receiveTimeout` set. + +--- + +### 2. TLS and Certificate Validation + +Dio uses `dart:io`'s `HttpClient` on native platforms, which validates the +server certificate against the system trust store by default. Do not disable +this: + +```dart +// ❌ NEVER do this in production — disables ALL certificate validation +IOHttpClientAdapter( + createHttpClient: () => HttpClient() + ..badCertificateCallback = (cert, host, port) => true, +) +``` + +**Critical warning:** Setting `badCertificateCallback` to return `true` +causes `dart:io` to accept any certificate *before* Dio's own +`validateCertificate` callback is invoked, silently bypassing all pinning. + +#### Certificate Pinning + +Use `IOHttpClientAdapter.validateCertificate` together with the built-in +`CertificatePinner` helper (available via `package:dio/io.dart`): + +```dart +import 'package:dio/io.dart'; + +final pinner = CertificatePinner( + // Get fingerprint with: + // openssl x509 -in cert.pem -fingerprint -sha1 -noout + allowedSHA1Fingerprints: {'AA:BB:CC:DD:EE:FF:...'}, +); + +final dio = Dio(); +dio.httpClientAdapter = IOHttpClientAdapter( + validateCertificate: pinner.validate, +); +``` + +For SHA-256 pinning, add the `crypto` package and provide a custom +`ValidateCertificate` callback that computes +`sha256.convert(cert!.der).toString()`. + +**Note:** `validateCertificate` only receives the *leaf* certificate. Pinning +to an intermediate CA or root requires a custom callback that inspects the +full chain via `SecurityContext`. + +--- + +### 3. Redirect Security + +By default Dio follows up to 5 redirects (`followRedirects: true`, +`maxRedirects: 5`). Redirects are handled manually by Dio (not delegated to +`dart:io`) so that **credential headers are stripped when a redirect crosses +an origin boundary** (different host or scheme), in accordance with +[RFC 9110 §15.4](https://www.rfc-editor.org/rfc/rfc9110#section-15.4). + +Headers stripped on cross-origin redirects: `Authorization`, +`Proxy-Authorization`, `Cookie`. + +To disable redirect following entirely: + +```dart +final response = await dio.get( + url, + options: Options(followRedirects: false), +); +``` + +--- + +### 4. Logging — Do Not Expose Credentials + +`LogInterceptor` masks sensitive headers by default using +`LogInterceptor.defaultRedactedHeaders`: + +```text +authorization, proxy-authorization, cookie, set-cookie, +x-api-key, x-auth-token, x-csrf-token +``` + +These are replaced with `**REDACTED**` in all log output. To customise: + +```dart +// Add extra headers to mask +dio.interceptors.add( + LogInterceptor( + redactedHeaders: { + ...LogInterceptor.defaultRedactedHeaders, + 'x-my-secret-header', + }, + ), +); + +// Disable masking (local development only — never in production) +dio.interceptors.add(LogInterceptor(redactedHeaders: {})); +``` + +Do **not** enable `LogInterceptor` in release builds without confirming that +credential headers are masked, as logs are accessible via `adb logcat` and +may be forwarded to crash-reporting services. + +--- + +### 5. Response Size Limits + +Dio does not cap response body size by default. A server can stream an +arbitrarily large body into memory. Set `maxResponseSize` to guard against +this: + +```dart +final dio = Dio( + BaseOptions( + maxResponseSize: 10 * 1024 * 1024, // 10 MB global limit + ), +); + +// Or per-request +final response = await dio.get( + url, + options: Options(maxResponseSize: 1 * 1024 * 1024), // 1 MB +); +``` + +When the limit is exceeded, a `DioException` with type +`DioExceptionType.badResponse` is thrown and the connection is closed +immediately without buffering the remaining bytes. + +--- + +### 6. Multipart Boundary Generation + +Multipart form-data boundaries are generated with `dart:math`'s +`Random.secure()` (a CSPRNG backed by the OS entropy source). This prevents +boundary prediction and multipart-injection attacks. + +--- + +### 7. Platform-Specific Notes + +#### iOS — App Transport Security (ATS) + +iOS enforces HTTPS and TLS 1.2+ by default. HTTP connections require an ATS +exception in `Info.plist`. Avoid broad `NSAllowsArbitraryLoads` exceptions; +use per-domain exceptions instead. + +#### Android — Network Security Config + +Android 9+ blocks cleartext HTTP by default. Configure a +`network_security_config.xml` if you need to pin certificates or allow +specific cleartext domains. Never use `android:usesCleartextTraffic="true"` +globally in production. + +--- + +### 8. Cookie Security + +When using `dio_cookie_manager`, cookies are stored and replayed by the +`cookie_jar` package. Note that Dio does not inspect `HttpOnly` or `Secure` +cookie flags — enforcement of those flags depends on the `cookie_jar` +implementation and your storage configuration. Use an encrypted cookie jar +for sensitive session cookies. + +--- + +### 9. Production Baseline + +```dart +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; + +Dio createSecureDio() { + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + maxResponseSize: 10 * 1024 * 1024, // 10 MB + ), + ); + + // Certificate pinning (replace fingerprint with your certificate's) + final pinner = CertificatePinner( + allowedSHA1Fingerprints: {'AA:BB:CC:DD:EE:FF:00:11:22:33:...'}, + ); + dio.httpClientAdapter = IOHttpClientAdapter( + validateCertificate: pinner.validate, + ); + + // Logging only in debug; credentials are masked by default + assert(() { + dio.interceptors.add(LogInterceptor()); + return true; + }()); + + return dio; +} +``` diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart index 20553fef5..0bb0e674c 100644 --- a/dio/lib/src/adapters/io_adapter.dart +++ b/dio/lib/src/adapters/io_adapter.dart @@ -25,6 +25,94 @@ typedef ValidateCertificate = bool Function( int port, ); +/// A helper that builds a [ValidateCertificate] callback for fingerprint-based +/// certificate pinning. +/// +/// Pinning is done by comparing the leaf certificate's SHA-1 fingerprint +/// (available natively on all platforms via [X509Certificate.sha1]) against a +/// set of known-good values. For SHA-256 pinning, add the `crypto` package and +/// pass a custom [ValidateCertificate] that computes +/// `sha256.convert(cert!.der).toString()`. +/// +/// **Usage:** +/// ```dart +/// final pinner = CertificatePinner( +/// // Obtain with: openssl x509 -in cert.pem -fingerprint -sha1 -noout +/// allowedSHA1Fingerprints: {'AA:BB:CC:DD:EE:...'}, +/// ); +/// +/// final dio = Dio(); +/// dio.httpClientAdapter = IOHttpClientAdapter( +/// validateCertificate: pinner.validate, +/// ); +/// ``` +/// +/// **Warning:** Setting `badCertificateCallback: (cert, host, port) => true` +/// on the underlying [HttpClient] (via [IOHttpClientAdapter.createHttpClient]) +/// causes dart:io to accept any certificate before Dio's [validateCertificate] +/// is ever called, silently bypassing all pinning. Never use that callback in +/// production. +class CertificatePinner { + CertificatePinner({ + this.allowedSHA1Fingerprints = const {}, + this.allowedDERCertificates = const {}, + }) : assert( + allowedSHA1Fingerprints.isNotEmpty || + allowedDERCertificates.isNotEmpty, + 'Provide at least one allowedSHA1Fingerprints entry or ' + 'allowedDERCertificates entry.', + ); + + /// Allowed SHA-1 fingerprints in colon-separated uppercase hex form, + /// e.g. `'AA:BB:CC:...'`, as printed by: + /// `openssl x509 -in cert.pem -fingerprint -sha1 -noout` + /// + /// Comparison is case-insensitive. + final Set allowedSHA1Fingerprints; + + /// Allowed certificates as raw DER-encoded bytes for exact byte comparison. + final Set> allowedDERCertificates; + + /// A [ValidateCertificate] callback for [IOHttpClientAdapter.validateCertificate]. + bool validate(X509Certificate? certificate, String host, int port) { + if (certificate == null) { + return false; + } + + if (allowedSHA1Fingerprints.isNotEmpty) { + final fingerprint = _toColonHex(certificate.sha1).toUpperCase(); + if (allowedSHA1Fingerprints.any((f) => f.toUpperCase() == fingerprint)) { + return true; + } + } + + if (allowedDERCertificates.isNotEmpty) { + final der = certificate.der; + if (allowedDERCertificates.any((allowed) => _bytesEqual(allowed, der))) { + return true; + } + } + + return false; + } + + static String _toColonHex(List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + } + + static bool _bytesEqual(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; + } +} + /// Creates an [IOHttpClientAdapter]. HttpClientAdapter createAdapter() => IOHttpClientAdapter(); @@ -52,6 +140,12 @@ class IOHttpClientAdapter implements HttpClientAdapter { /// [badCertificateCallback] accept the certificate chain. Those /// methods evaluate the root or intermediate certificate, while /// [validateCertificate] evaluates the leaf certificate. + /// + /// **Security warning:** If you set `badCertificateCallback: (_, __, ___) => true` + /// on the [HttpClient] created by [createHttpClient], dart:io will accept + /// any certificate before this callback is reached, silently bypassing all + /// certificate validation. Never use that pattern in production. + /// Use [CertificatePinner] to build a pinning callback from SHA-1 fingerprints. ValidateCertificate? validateCertificate; HttpClient? _cachedHttpClient; @@ -77,149 +171,234 @@ class IOHttpClientAdapter implements HttpClientAdapter { Future? cancelFuture, ) async { final httpClient = _configHttpClient(options.connectTimeout); - final reqFuture = httpClient.openUrl(options.method, options.uri); - late HttpClientRequest request; - try { - final connectionTimeout = options.connectTimeout; - if (connectionTimeout != null && connectionTimeout > Duration.zero) { - request = await reqFuture.timeout( - connectionTimeout, - onTimeout: () { - throw DioException.connectionTimeout( - requestOptions: options, - timeout: connectionTimeout, - ); - }, - ); - } else { - request = await reqFuture; - } - final requestWR = WeakReference(request); - cancelFuture?.whenComplete(() { - requestWR.target?.abort(); - }); + // Mutable pointer updated each loop iteration so cancellation always + // aborts the currently-active request, not a stale one. + HttpClientRequest? activeRequest; + cancelFuture?.whenComplete(() { + activeRequest?.abort(); + }); + + final redirects = []; + var currentUri = options.uri; + var currentMethod = options.method; + // Copy so cross-origin header stripping never mutates the original options. + var currentHeaders = Map.from(options.headers); + // A request body stream can only be consumed once. It is sent on the first + // hop only; 307/308 redirects that require body replay are followed without + // a body (stream replay is not supported). + var sendBodyStream = true; + + while (true) { + final reqFuture = httpClient.openUrl(currentMethod, currentUri); + late HttpClientRequest request; - // Set Headers - options.headers.forEach((key, value) { - if (value != null) { - request.headers.set( - key, - value, - preserveHeaderCase: options.preserveHeaderCase, + try { + final connectionTimeout = options.connectTimeout; + if (connectionTimeout != null && connectionTimeout > Duration.zero) { + request = await reqFuture.timeout( + connectionTimeout, + onTimeout: () { + throw DioException.connectionTimeout( + requestOptions: options, + timeout: connectionTimeout, + ); + }, ); - } - }); - } on SocketException catch (e) { - if (e.message.contains('timed out')) { - final Duration effectiveTimeout; - if (options.connectTimeout != null && - options.connectTimeout! > Duration.zero) { - effectiveTimeout = options.connectTimeout!; - } else if (httpClient.connectionTimeout != null && - httpClient.connectionTimeout! > Duration.zero) { - effectiveTimeout = httpClient.connectionTimeout!; } else { - effectiveTimeout = Duration.zero; + request = await reqFuture; + } + + activeRequest = request; + + currentHeaders.forEach((key, value) { + if (value != null) { + request.headers.set( + key, + value, + preserveHeaderCase: options.preserveHeaderCase, + ); + } + }); + } on SocketException catch (e) { + if (e.message.contains('timed out')) { + final Duration effectiveTimeout; + if (options.connectTimeout != null && + options.connectTimeout! > Duration.zero) { + effectiveTimeout = options.connectTimeout!; + } else if (httpClient.connectionTimeout != null && + httpClient.connectionTimeout! > Duration.zero) { + effectiveTimeout = httpClient.connectionTimeout!; + } else { + effectiveTimeout = Duration.zero; + } + throw DioException.connectionTimeout( + requestOptions: options, + timeout: effectiveTimeout, + error: e, + ); } - throw DioException.connectionTimeout( + throw DioException.connectionError( requestOptions: options, - timeout: effectiveTimeout, + reason: e.message, error: e, ); } - throw DioException.connectionError( - requestOptions: options, - reason: e.message, - error: e, - ); - } - request.followRedirects = options.followRedirects; - request.maxRedirects = options.maxRedirects; - request.persistentConnection = options.persistentConnection; + // Always disable dart:io's automatic redirect following. Dio handles + // redirects manually so it can strip sensitive credential headers when + // a redirect crosses an origin boundary (RFC 9110 §15.4). + request.followRedirects = false; + request.maxRedirects = 0; + request.persistentConnection = options.persistentConnection; + + if (sendBodyStream && requestStream != null) { + Future future = request.addStream(requestStream); + final sendTimeout = options.sendTimeout; + if (sendTimeout != null && sendTimeout > Duration.zero) { + future = future.timeout( + sendTimeout, + onTimeout: () { + request.abort(); + throw DioException.sendTimeout( + timeout: sendTimeout, + requestOptions: options, + ); + }, + ); + } + await future; + // Stream consumed; cannot be replayed on subsequent redirect hops. + sendBodyStream = false; + } - if (requestStream != null) { - // Transform the request data. - Future future = request.addStream(requestStream); - final sendTimeout = options.sendTimeout; - if (sendTimeout != null && sendTimeout > Duration.zero) { + Future future = request.close(); + final receiveTimeout = options.receiveTimeout ?? Duration.zero; + if (receiveTimeout > Duration.zero) { future = future.timeout( - sendTimeout, + receiveTimeout, onTimeout: () { request.abort(); - throw DioException.sendTimeout( - timeout: sendTimeout, + throw DioException.receiveTimeout( + timeout: receiveTimeout, requestOptions: options, ); }, ); } - await future; - } + final responseStream = await future; - Future future = request.close(); - final receiveTimeout = options.receiveTimeout ?? Duration.zero; - if (receiveTimeout > Duration.zero) { - future = future.timeout( - receiveTimeout, - onTimeout: () { - request.abort(); - throw DioException.receiveTimeout( - timeout: receiveTimeout, + // Validate certificate on every hop, not just the first. + if (validateCertificate != null) { + final host = currentUri.host; + final port = currentUri.port; + final bool isCertApproved = validateCertificate!( + responseStream.certificate, + host, + port, + ); + if (!isCertApproved) { + throw DioException.badCertificate( requestOptions: options, + error: responseStream.certificate, ); - }, - ); - } - final responseStream = await future; - - if (validateCertificate != null) { - final host = options.uri.host; - final port = options.uri.port; - final bool isCertApproved = validateCertificate!( - responseStream.certificate, - host, - port, + } + } + + final statusCode = responseStream.statusCode; + + // Follow redirects with per-hop security enforcement. + if (options.followRedirects && _isRedirectStatus(statusCode)) { + final location = responseStream.headers.value('location'); + if (location != null) { + if (redirects.length >= options.maxRedirects) { + await responseStream.drain(); + throw DioException.connectionError( + requestOptions: options, + reason: 'Redirect limit (${options.maxRedirects}) exceeded.', + ); + } + + final redirectUri = currentUri.resolve(location); + final newMethod = _resolveRedirectMethod(currentMethod, statusCode); + redirects.add(RedirectRecord(statusCode, newMethod, redirectUri)); + + // RFC 9110 §15.4: strip credential headers when the redirect + // crosses an origin boundary (different host or scheme). + if (redirectUri.host != currentUri.host || + redirectUri.scheme != currentUri.scheme) { + currentHeaders = Map.from(currentHeaders); + for (final h in _sensitiveRedirectHeaders) { + currentHeaders.remove(h); + } + } + + currentUri = redirectUri; + currentMethod = newMethod; + activeRequest = null; + await responseStream.drain(); + continue; + } + } + + // Not a redirect (or redirect without Location header): build response. + final headers = >{}; + responseStream.headers.forEach((key, values) { + headers[key] = values; + }); + + // Extract HTTP protocol version from the response headers. + // The protocolVersion is available in the internal `_HttpHeaders` + // implementation but not exposed in the public `HttpHeaders` interface, + // so we use dynamic access. This may fail in certain environments + // (e.g., tests with mocks), so we catch and omit errors. + String? httpVersion; + try { + httpVersion = (responseStream.headers as dynamic).protocolVersion; + } catch (_) {} + + final responseBody = ResponseBody( + responseStream.cast(), + statusCode, + headers: headers, + isRedirect: redirects.isNotEmpty, + redirects: redirects, + statusMessage: responseStream.reasonPhrase, ); - if (!isCertApproved) { - throw DioException.badCertificate( - requestOptions: options, - error: responseStream.certificate, - ); + if (httpVersion != null) { + responseBody.extra[HttpClientAdapter.extraKeyHttpVersion] ??= + httpVersion; } + return responseBody; } + } - final headers = >{}; - responseStream.headers.forEach((key, values) { - headers[key] = values; - }); + // Headers that must be stripped when a redirect crosses an origin boundary. + // Based on RFC 9110 §15.4 and the Fetch specification. + static const _sensitiveRedirectHeaders = { + 'authorization', + 'proxy-authorization', + 'cookie', + }; + + static bool _isRedirectStatus(int statusCode) { + return statusCode == 301 || + statusCode == 302 || + statusCode == 303 || + statusCode == 307 || + statusCode == 308; + } - // Extract HTTP protocol version from the response headers. - // The protocolVersion is available in the internal `_HttpHeaders` - // implementation but not exposed in the public `HttpHeaders` interface, - // so we use dynamic access. This may fail in certain environments - // (e.g., tests with mocks), so we catch and omit errors. - String? httpVersion; - try { - httpVersion = (responseStream.headers as dynamic).protocolVersion; - } catch (_) {} - - final responseBody = ResponseBody( - responseStream.cast(), - responseStream.statusCode, - headers: headers, - isRedirect: - responseStream.isRedirect || responseStream.redirects.isNotEmpty, - redirects: responseStream.redirects - .map((e) => RedirectRecord(e.statusCode, e.method, e.location)) - .toList(), - statusMessage: responseStream.reasonPhrase, - ); - if (httpVersion != null) { - responseBody.extra[HttpClientAdapter.extraKeyHttpVersion] ??= httpVersion; + // 303 always changes to GET. 301/302 with POST conventionally change to GET + // (RFC 7231 recommendation followed by all major browsers/clients). + static String _resolveRedirectMethod(String method, int statusCode) { + if (statusCode == 303) { + return 'GET'; + } + if ((statusCode == 301 || statusCode == 302) && method == 'POST') { + return 'GET'; } - return responseBody; + return method; } HttpClient _configHttpClient(Duration? connectionTimeout) { diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index 405be15d1..adb8cd85c 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -21,6 +21,7 @@ import 'progress_stream/io_progress_stream.dart' import 'response.dart'; import 'response/response_stream_handler.dart'; import 'transformer.dart'; +import 'utils.dart'; part 'interceptor.dart'; @@ -566,6 +567,25 @@ abstract class DioMixin implements Dio { } Future> _dispatchRequest(RequestOptions reqOpt) async { + // Warn developers when no timeouts are configured. Requests with null + // timeouts will hang indefinitely, which can exhaust connections and freeze + // UI. This warning is suppressed in release builds via warningLog. + if (reqOpt.connectTimeout == null) { + warningLog( + 'connectTimeout is not set on this request. ' + 'Without a timeout the request may hang indefinitely. ' + 'Set connectTimeout in BaseOptions or per-request Options.', + StackTrace.current, + ); + } + if (reqOpt.receiveTimeout == null) { + warningLog( + 'receiveTimeout is not set on this request. ' + 'Without a timeout the request may hang indefinitely. ' + 'Set receiveTimeout in BaseOptions or per-request Options.', + StackTrace.current, + ); + } final cancelToken = reqOpt.cancelToken; try { final stream = await _transformData(reqOpt); diff --git a/dio/lib/src/form_data.dart b/dio/lib/src/form_data.dart index 2fc4cd548..06c254087 100644 --- a/dio/lib/src/form_data.dart +++ b/dio/lib/src/form_data.dart @@ -11,11 +11,12 @@ const _boundaryName = '--dio-boundary'; const _rn = '\r\n'; final _rnU8 = Uint8List.fromList([13, 10]); -const _secureRandomSeedBound = 4294967296; -final _random = math.Random(); +// Use a cryptographically secure RNG so multipart boundaries cannot be +// predicted by an attacker who knows the approximate request timestamp. +final _random = math.Random.secure(); String get _nextRandomId => - _random.nextInt(_secureRandomSeedBound).toString().padLeft(10, '0'); + _random.nextInt(4294967296).toString().padLeft(10, '0'); /// A class to create readable "multipart/form-data" streams. /// It can be used to submit forms and file uploads to http server. diff --git a/dio/lib/src/interceptors/log.dart b/dio/lib/src/interceptors/log.dart index 4806b9a72..64e638164 100644 --- a/dio/lib/src/interceptors/log.dart +++ b/dio/lib/src/interceptors/log.dart @@ -19,6 +19,13 @@ import '../response.dart'; /// ), /// ); /// ``` +/// +/// **Security note:** By default, headers listed in [defaultRedactedHeaders] +/// (e.g. `Authorization`, `Cookie`, `X-API-Key`) are replaced with +/// `**REDACTED**` in log output to prevent credential leakage via logs, +/// crash reporters, or screen-sharing. Pass a custom [redactedHeaders] set +/// to change which headers are masked, or pass an empty set to disable +/// redaction entirely (e.g. for local development only). class LogInterceptor extends Interceptor { LogInterceptor({ this.request = true, @@ -30,8 +37,22 @@ class LogInterceptor extends Interceptor { this.responseBody = false, this.error = true, this.logPrint = _debugPrint, + this.redactedHeaders = defaultRedactedHeaders, }); + /// The default set of header keys whose values are masked in log output. + /// + /// Comparison is case-insensitive. All keys are stored in lower-case. + static const Set defaultRedactedHeaders = { + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'x-csrf-token', + }; + /// Print request [RequestOptions] bool request; @@ -68,6 +89,12 @@ class LogInterceptor extends Interceptor { /// ``` void Function(Object object) logPrint; + /// Header keys whose values are replaced with `**REDACTED**` in log output. + /// + /// Comparison is case-insensitive. Defaults to [defaultRedactedHeaders]. + /// Pass an empty set to disable redaction (e.g. for local development). + Set redactedHeaders; + @override void onRequest( RequestOptions options, @@ -125,7 +152,9 @@ class LogInterceptor extends Interceptor { if (requestHeader) { logPrint('headers:'); - options.headers.forEach((key, v) => _printKV(' $key', v)); + options.headers.forEach( + (key, v) => _printKV(' $key', _redactHeaderValue(key, v)), + ); } if (requestBody) { @@ -156,7 +185,10 @@ class LogInterceptor extends Interceptor { } logPrint('headers:'); - response.headers.forEach((key, v) => _printKV(' $key', v.join('\r\n\t'))); + response.headers.forEach( + (key, v) => + _printKV(' $key', _redactHeaderValue(key, v.join('\r\n\t'))), + ); } if (responseBody) { @@ -174,6 +206,13 @@ class LogInterceptor extends Interceptor { void _printAll(Object? msg) { msg.toString().split('\n').forEach(logPrint); } + + Object? _redactHeaderValue(String key, Object? value) { + if (redactedHeaders.contains(key.toLowerCase())) { + return '**REDACTED**'; + } + return value; + } } void _debugPrint(Object? object) { diff --git a/dio/lib/src/options.dart b/dio/lib/src/options.dart index 143790d7a..7a7c84fa1 100644 --- a/dio/lib/src/options.dart +++ b/dio/lib/src/options.dart @@ -159,6 +159,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin { super.requestEncoder, super.responseDecoder, super.listFormat, + super.maxResponseSize, }) { this.baseUrl = baseUrl; this.queryParameters = queryParameters ?? {}; @@ -187,6 +188,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin { RequestEncoder? requestEncoder, ResponseDecoder? responseDecoder, ListFormat? listFormat, + int? maxResponseSize, }) { return BaseOptions( method: method ?? this.method, @@ -209,6 +211,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin { requestEncoder: requestEncoder ?? this.requestEncoder, responseDecoder: responseDecoder ?? this.responseDecoder, listFormat: listFormat ?? this.listFormat, + maxResponseSize: maxResponseSize ?? this.maxResponseSize, ); } } @@ -234,6 +237,7 @@ class Options { this.requestEncoder, this.responseDecoder, this.listFormat, + this.maxResponseSize, }) : assert(receiveTimeout == null || !receiveTimeout.isNegative), _receiveTimeout = receiveTimeout, assert(sendTimeout == null || !sendTimeout.isNegative), @@ -260,6 +264,7 @@ class Options { RequestEncoder? requestEncoder, ResponseDecoder? responseDecoder, ListFormat? listFormat, + int? maxResponseSize, }) { Map? effectiveHeaders; if (headers == null && this.headers != null) { @@ -299,6 +304,7 @@ class Options { requestEncoder: requestEncoder ?? this.requestEncoder, responseDecoder: responseDecoder ?? this.responseDecoder, listFormat: listFormat ?? this.listFormat, + maxResponseSize: maxResponseSize ?? this.maxResponseSize, ); } @@ -357,6 +363,7 @@ class Options { requestEncoder: requestEncoder ?? baseOpt.requestEncoder, responseDecoder: responseDecoder ?? baseOpt.responseDecoder, listFormat: listFormat ?? baseOpt.listFormat, + maxResponseSize: maxResponseSize ?? baseOpt.maxResponseSize, onReceiveProgress: onReceiveProgress, onSendProgress: onSendProgress, cancelToken: cancelToken, @@ -502,6 +509,15 @@ class Options { /// /// Defaults to [ListFormat.multi]. ListFormat? listFormat; + + /// Maximum number of bytes to accept in the response body. + /// + /// When set, Dio aborts the response stream with a [DioException] of type + /// [DioExceptionType.badResponse] if the accumulated bytes exceed this + /// limit, preventing memory exhaustion from large server responses. + /// + /// `null` means no limit (the default). + int? maxResponseSize; } /// The internal request option class that is the eventual result after @@ -532,6 +548,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin { super.requestEncoder, super.responseDecoder, super.listFormat, + super.maxResponseSize, bool? setRequestContentTypeWhenNoPayload, StackTrace? sourceStackTrace, }) : assert(connectTimeout == null || !connectTimeout.isNegative) { @@ -567,6 +584,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin { RequestEncoder? requestEncoder, ResponseDecoder? responseDecoder, ListFormat? listFormat, + int? maxResponseSize, bool? setRequestContentTypeWhenNoPayload, }) { final contentTypeInHeader = headers != null && @@ -604,6 +622,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin { requestEncoder: requestEncoder ?? this.requestEncoder, responseDecoder: responseDecoder ?? this.responseDecoder, listFormat: listFormat ?? this.listFormat, + maxResponseSize: maxResponseSize ?? this.maxResponseSize, sourceStackTrace: sourceStackTrace, ); @@ -682,6 +701,7 @@ class _RequestConfig { ResponseType? responseType, this.requestEncoder, this.responseDecoder, + this.maxResponseSize, }) : assert(receiveTimeout == null || !receiveTimeout.isNegative), _receiveTimeout = receiveTimeout, assert(sendTimeout == null || !sendTimeout.isNegative), @@ -773,6 +793,16 @@ class _RequestConfig { RequestEncoder? requestEncoder; ResponseDecoder? responseDecoder; late ListFormat listFormat; + + /// Maximum number of bytes to accept in the response body. + /// + /// When set, [handleResponseStream] will abort with a [DioException] of + /// type [DioExceptionType.badResponse] if the accumulated response bytes + /// exceed this limit. This prevents memory exhaustion from unexpectedly + /// large server responses. + /// + /// `null` means no limit (the default). + int? maxResponseSize; } /// {@template dio.options.FileAccessMode} diff --git a/dio/lib/src/response/response_stream_handler.dart b/dio/lib/src/response/response_stream_handler.dart index ad6f32b4a..7f5aeeea1 100644 --- a/dio/lib/src/response/response_stream_handler.dart +++ b/dio/lib/src/response/response_stream_handler.dart @@ -29,6 +29,7 @@ Stream handleResponseStream( if (options.onReceiveProgress != null) { totalLength = response.contentLength; } + final maxResponseSize = options.maxResponseSize; final receiveTimeout = options.receiveTimeout ?? Duration.zero; final receiveStopwatch = Stopwatch(); @@ -71,11 +72,27 @@ Stream handleResponseStream( // headers and body (see onData below for per-chunk reset). watchReceiveTimeout(); + int receivedBytes = 0; responseSubscription = source.listen( (data) { watchReceiveTimeout(); // Always true if the receive timeout was not set. if (receiveStopwatch.elapsed <= receiveTimeout) { + receivedBytes += data.length; + if (maxResponseSize != null && receivedBytes > maxResponseSize) { + stopWatchReceiveTimeout(); + response.close(); + responseSubscription.cancel(); + responseSink.addErrorAndClose( + DioException( + type: DioExceptionType.badResponse, + requestOptions: options, + message: 'Response size ($receivedBytes bytes) exceeded the ' + 'maxResponseSize limit ($maxResponseSize bytes).', + ), + ); + return; + } responseSink.add(data); options.onReceiveProgress?.call( receivedLength += data.length,