Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion plugins/web_adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 44 additions & 2 deletions plugins/web_adapter/lib/src/adapter_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This export makes corsPreflightReason a permanent public API of dio_web_adapter, but the test file already imports it via package:dio_web_adapter/src/cors.dart and 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 the export. Adding public surface for testing only is a compat trap.


BrowserHttpClientAdapter createAdapter() => BrowserHttpClientAdapter();

/// The default [HttpClientAdapter] for Web platforms.
Expand Down Expand Up @@ -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>[

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three new branches added by this PR — willRegisterUploadListener (sendTimeout / onSendProgress), withCredentials, and the enriched onError message construction below — are not exercised by the new tests. All 15 tests cover only corsPreflightReason (the pure helper). The coverage bump on this file (19.27% → 22.41%) doesn't show whether these specific lines were hit. Could you either add a browser-side integration case in dio_test for these paths, or lift them into pure helpers alongside corsPreflightReason so they can be unit-tested too?

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 debugPrint), or dropping the log to a lower verbosity when the app has no way to know whether preflight succeeded? A permanent warning per request seems louder than the underlying issue warrants.

'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((_) {
Expand Down Expand Up @@ -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,
);
Expand Down
53 changes: 53 additions & 0 deletions plugins/web_adapter/lib/src/cors.dart
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;
}
130 changes: 130 additions & 0 deletions plugins/web_adapter/test/cors_preflight_test.dart
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);
});
});
}
Loading