-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat: warn about CORS preflight requests on Web #2556
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = <String>[ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The three new branches added by this PR — |
||
| 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will fire on essentially every JSON POST in every dio-on-Web app — including the (very common) case where the backend handles CORS preflight correctly. Would you consider an opt-out (e.g., an option on the adapter, or a setter akin to |
||
| '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<ResponseBody>(); | ||
|
|
||
| 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, | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = <String>{ | ||
| 'accept', | ||
| 'accept-language', | ||
| 'content-language', | ||
| 'content-type', | ||
| 'range', | ||
| }; | ||
|
|
||
| /// Content-Type values that the CORS spec treats as "simple". | ||
| const corsSimpleContentTypes = <String>{ | ||
| '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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
exportmakescorsPreflightReasona permanent public API ofdio_web_adapter, but the test file already imports it viapackage:dio_web_adapter/src/cors.dartand doesn't need the export. Unless we intentionally want to expose this as a downstream API (with a CHANGELOG note and awareness that future signature changes would be breaking), please remove theexport. Adding public surface for testing only is a compat trap.