diff --git a/plugins/web_adapter/CHANGELOG.md b/plugins/web_adapter/CHANGELOG.md index 60cf64c0e..2cb3b7e0c 100644 --- a/plugins/web_adapter/CHANGELOG.md +++ b/plugins/web_adapter/CHANGELOG.md @@ -2,7 +2,9 @@ ## Unreleased -*None.* +- Warn when a request is not a CORS "simple request" and will trigger a + preflight (OPTIONS) request, and enrich the `XMLHttpRequest.onError` message + with CORS guidance when the request was preflighted. ## 2.2.0 diff --git a/plugins/web_adapter/lib/src/adapter_impl.dart b/plugins/web_adapter/lib/src/adapter_impl.dart index 5374e2076..b307d2880 100644 --- a/plugins/web_adapter/lib/src/adapter_impl.dart +++ b/plugins/web_adapter/lib/src/adapter_impl.dart @@ -8,6 +8,10 @@ import 'package:dio/src/utils.dart'; import 'package:meta/meta.dart'; import 'package:web/web.dart' as web; +import 'cors.dart'; + +export 'cors.dart' show corsPreflightReason; + BrowserHttpClientAdapter createAdapter() => BrowserHttpClientAdapter(); /// The default [HttpClientAdapter] for Web platforms. @@ -63,6 +67,35 @@ class BrowserHttpClientAdapter implements HttpClientAdapter { final xhrTimeout = (connectTimeout + receiveTimeout).inMilliseconds; xhr.timeout = xhrTimeout; + // Detect configurations that will trigger a CORS preflight request so we + // can warn the developer and enrich the eventual error message. + // See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests + final preflightReasons = [ + if (corsPreflightReason(options) case final reason?) reason, + ]; + final willRegisterUploadListener = requestStream != null && + (sendTimeout > Duration.zero || onSendProgress != null); + if (willRegisterUploadListener) { + preflightReasons.add( + 'an upload progress listener (sendTimeout or onSendProgress) ' + 'forces a preflight request', + ); + } + if (xhr.withCredentials) { + preflightReasons.add( + 'withCredentials is enabled, which requires a CORS preflight request', + ); + } + if (preflightReasons.isNotEmpty) { + warningLog( + 'This request is not a CORS "simple request" and will trigger a ' + 'preflight (OPTIONS) request: ${preflightReasons.join('; ')}. ' + 'If the server does not handle CORS preflight, the request will fail. ' + 'See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests', + StackTrace.current, + ); + } + final completer = Completer(); xhr.onLoad.first.then((_) { @@ -219,11 +252,20 @@ class BrowserHttpClientAdapter implements HttpClientAdapter { // Unfortunately, the underlying XMLHttpRequest API doesn't expose any // specific information about the error itself. // See also: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestEventTarget/onerror + final baseReason = 'The XMLHttpRequest onError callback was called. ' + 'This typically indicates an error on the network layer.'; + final reason = preflightReasons.isEmpty + ? baseReason + : '$baseReason If this is a cross-origin request, the browser may ' + 'have blocked it because the request is not a CORS "simple ' + 'request" (${preflightReasons.join('; ')}). Verify that the ' + 'server responds correctly to the CORS preflight (OPTIONS) ' + 'request. See ' + 'https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests'; completer.completeError( DioException.connectionError( requestOptions: options, - reason: 'The XMLHttpRequest onError callback was called. ' - 'This typically indicates an error on the network layer.', + reason: reason, ), StackTrace.current, ); diff --git a/plugins/web_adapter/lib/src/cors.dart b/plugins/web_adapter/lib/src/cors.dart new file mode 100644 index 000000000..55d254024 --- /dev/null +++ b/plugins/web_adapter/lib/src/cors.dart @@ -0,0 +1,53 @@ +import 'package:dio/dio.dart'; + +/// Headers that the CORS spec exempts from preflight for "simple requests". +/// +/// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests +const corsSafelistedRequestHeaders = { + 'accept', + 'accept-language', + 'content-language', + 'content-type', + 'range', +}; + +/// Content-Type values that the CORS spec treats as "simple". +const corsSimpleContentTypes = { + 'application/x-www-form-urlencoded', + 'multipart/form-data', + 'text/plain', +}; + +/// Returns a human-readable reason when [options] is likely to trigger a CORS +/// preflight request on the Web platform, or `null` when the request matches +/// the "simple request" criteria. +/// +/// This is a pure function over [RequestOptions] so it can be unit-tested +/// without a browser. The upload-progress listener is not considered here +/// because it depends on runtime wiring inside the adapter; callers that +/// register an upload listener should append that reason themselves. +String? corsPreflightReason(RequestOptions options) { + final method = options.method.toUpperCase(); + if (method != 'GET' && method != 'HEAD' && method != 'POST') { + return 'the request method "$method" is not a CORS-safelisted method ' + '(GET, HEAD, POST)'; + } + final contentType = options.headers[Headers.contentTypeHeader]; + final contentTypeValue = contentType is List && contentType.isNotEmpty + ? contentType.first.toString() + : contentType?.toString(); + if (contentTypeValue != null && contentTypeValue.isNotEmpty) { + final mimeType = contentTypeValue.split(';').first.trim().toLowerCase(); + if (mimeType.isNotEmpty && !corsSimpleContentTypes.contains(mimeType)) { + return 'the Content-Type "$contentTypeValue" is not a CORS-safelisted ' + 'value (application/x-www-form-urlencoded, multipart/form-data, ' + 'text/plain)'; + } + } + for (final key in options.headers.keys) { + if (!corsSafelistedRequestHeaders.contains(key.toLowerCase())) { + return 'the request header "$key" is not on the CORS safelist'; + } + } + return null; +} diff --git a/plugins/web_adapter/test/cors_preflight_test.dart b/plugins/web_adapter/test/cors_preflight_test.dart new file mode 100644 index 000000000..7fd006fdf --- /dev/null +++ b/plugins/web_adapter/test/cors_preflight_test.dart @@ -0,0 +1,130 @@ +import 'package:dio/dio.dart'; +import 'package:dio_web_adapter/src/cors.dart'; +import 'package:test/test.dart'; + +void main() { + group('corsPreflightReason', () { + test('GET with no extra headers is a simple request', () { + final options = RequestOptions(method: 'GET'); + expect(corsPreflightReason(options), isNull); + }); + + test('HEAD with no extra headers is a simple request', () { + final options = RequestOptions(method: 'HEAD'); + expect(corsPreflightReason(options), isNull); + }); + + test('POST with text/plain is a simple request', () { + final options = RequestOptions( + method: 'POST', + headers: {Headers.contentTypeHeader: Headers.textPlainContentType}, + ); + expect(corsPreflightReason(options), isNull); + }); + + test('POST with application/x-www-form-urlencoded is a simple request', () { + final options = RequestOptions( + method: 'POST', + headers: { + Headers.contentTypeHeader: Headers.formUrlEncodedContentType, + }, + ); + expect(corsPreflightReason(options), isNull); + }); + + test('POST with multipart/form-data is a simple request', () { + final options = RequestOptions( + method: 'POST', + headers: { + Headers.contentTypeHeader: Headers.multipartFormDataContentType, + }, + ); + expect(corsPreflightReason(options), isNull); + }); + + test('PUT is not a simple request', () { + final options = RequestOptions(method: 'PUT'); + final reason = corsPreflightReason(options); + expect(reason, isNotNull); + expect(reason, contains('PUT')); + expect(reason, contains('CORS-safelisted method')); + }); + + test('DELETE is not a simple request', () { + final options = RequestOptions(method: 'DELETE'); + expect(corsPreflightReason(options), contains('DELETE')); + }); + + test('PATCH is not a simple request', () { + final options = RequestOptions(method: 'PATCH'); + expect(corsPreflightReason(options), contains('PATCH')); + }); + + test('custom header is not a simple request', () { + final options = RequestOptions( + method: 'GET', + headers: {'x-custom-header': 'value'}, + ); + final reason = corsPreflightReason(options); + expect(reason, isNotNull); + expect(reason, contains('x-custom-header')); + expect(reason, contains('CORS safelist')); + }); + + test('application/json content type is not a simple request', () { + final options = RequestOptions( + method: 'POST', + headers: {Headers.contentTypeHeader: Headers.jsonContentType}, + ); + final reason = corsPreflightReason(options); + expect(reason, isNotNull); + expect(reason, contains('application/json')); + expect(reason, contains('CORS-safelisted value')); + }); + + test('content type with charset suffix is parsed by mime type', () { + final options = RequestOptions( + method: 'POST', + headers: {Headers.contentTypeHeader: 'text/plain; charset=utf-8'}, + ); + expect(corsPreflightReason(options), isNull); + }); + + test('application/json with charset is not a simple request', () { + final options = RequestOptions( + method: 'POST', + headers: { + Headers.contentTypeHeader: 'application/json; charset=utf-8', + }, + ); + expect(corsPreflightReason(options), contains('application/json')); + }); + + test('safelisted headers do not trigger preflight', () { + final options = RequestOptions( + method: 'GET', + headers: { + 'accept': 'application/json', + 'accept-language': 'en-US', + 'content-language': 'en', + }, + ); + expect(corsPreflightReason(options), isNull); + }); + + test('content-type header value as a list is handled', () { + final options = RequestOptions( + method: 'POST', + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + expect(corsPreflightReason(options), contains('application/json')); + }); + + test('method is case-insensitive', () { + final options = RequestOptions(method: 'get'); + expect(corsPreflightReason(options), isNull); + }); + }); +}