Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/devtools_app/lib/src/shared/server/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ Future<Response?> request(String url) async {
Response? response;

try {
response = await post(buildDevToolsServerRequestUri(url));
response = await post(
buildDevToolsServerRequestUri(url),
headers: const {devToolsRequestHeader: '1'},
);
} catch (_) {}

return response;
Expand Down
5 changes: 5 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ TODO: Remove this section if there are not any updates.
screens: colors that are hard to see against the current light or dark theme,
such as black or white, are now adjusted to remain legible.
[#10060](https://github.com/flutter/devtools/pull/10060)
* Validated incoming DevTools server requests by requiring `POST` (with
`X-DevTools-Request`) or `OPTIONS` methods and validating `Host`, `Origin`,
and `Sec-Fetch-Site` headers to protect against cross-site request forgery
(CSRF), DNS rebinding, and cross-origin attacks.
[#10082](https://github.com/flutter/devtools/pull/10082)

## Inspector updates

Expand Down
7 changes: 6 additions & 1 deletion packages/devtools_app/web/flutter_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ async function getDevToolsWasmOptOutPreference() {
// running from `flutter run` anyway.
const request = 'api/getPreferenceValue?key=experiment.wasmOptOut';
try {
const response = await fetch(request);
// This header name must match `devToolsRequestHeader` in
// `packages/devtools_shared/lib/src/devtools_api.dart`.
const response = await fetch(request, {
method: 'POST',
headers: { 'X-DevTools-Request': '1' },
});
if (!response.ok) {
console.warn(`[${response.status} response] ${request}`);
return false;
Expand Down
5 changes: 5 additions & 0 deletions packages/devtools_shared/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ found in the LICENSE file or at https://developers.google.com/open-source/licens
`DASH__SUPPRESS_ANALYTICS=true` is now passed on instead of being reset to
`false`.
* Require `package:unified_analytics` `^8.4.0`.
* Require `POST` (with `devToolsRequestHeader`) or `OPTIONS` CORS preflight
requests and validate `Host`, `Origin`, and `Sec-Fetch-Site` headers in
`ServerApi.handle` to prevent cross-site and DNS rebinding requests, with
optional `disableOriginCheck` and `allowedOrigins` parameters.
* Add `devToolsRequestHeader` constant (`X-DevTools-Request`).
* Reject `file://` URIs with non-empty hosts (UNC paths), absolute, missing, or
non-directory `buildLocation` paths, and symbolic links resolving outside the
extension build or package root in `ExtensionsManager`, and store valid
Expand Down
8 changes: 8 additions & 0 deletions packages/devtools_shared/lib/src/devtools_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
/// All server APIs prefix:
const apiPrefix = 'api/';

/// Custom HTTP header included on DevTools server API requests to force a CORS
/// preflight `OPTIONS` request in browsers and prevent simple cross-origin
/// `POST` requests.
///
/// This value must match the header sent in
/// `packages/devtools_app/web/flutter_bootstrap.js`.
const devToolsRequestHeader = 'X-DevTools-Request';

/// Key used for any request or response to specify a value argument.
const apiParameterValueKey = 'value';

Expand Down
202 changes: 202 additions & 0 deletions packages/devtools_shared/lib/src/server/server_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,224 @@ class ServerApi {
static const logsKey = 'logs';
static const errorKey = 'error';

static const _postMethod = 'POST';
static const _optionsMethod = 'OPTIONS';
static const _originHeader = 'origin';
static const _secFetchSiteHeader = 'sec-fetch-site';
static const _allowedSecFetchSitesWithoutOrigin = {'same-origin', 'none'};
static const _allowedSecFetchSitesWithOrigin = {
'same-origin',
'same-site',
'cross-site',
'none',
};

/// Determines whether or not [request] is an API call.
static bool canHandle(shelf.Request request) {
return request.url.path.startsWith(apiPrefix);
}

/// Validates [request] to mitigate CSRF, DNS rebinding, and cross-origin
/// attacks before dispatching to any [ServerApi] handler.
///
/// When [disableOriginCheck] is `true`, `Host`, `Origin`, and
/// `Sec-Fetch-Site` validation is skipped, while HTTP method and
/// [devToolsRequestHeader] checks are still enforced.
///
/// When [disableOriginCheck] is `false`, requests from loopback hosts/origins
/// and any URI in [allowedOrigins] are accepted.
///
/// Returns a [shelf.Response] if the request is rejected, or `null` if the
/// request is valid.
static shelf.Response? _validateRequest(
shelf.Request request,
ServerApi api, {
required bool disableOriginCheck,
required List<Uri> allowedOrigins,
}) {
final method = request.method.toUpperCase();
if (method != _postMethod && method != _optionsMethod) {
return shelf.Response(
HttpStatus.methodNotAllowed,
body: 'Method Not Allowed: ${request.method}',
headers: const <String, String>{
HttpHeaders.allowHeader: '$_postMethod, $_optionsMethod',
},
);
}
Comment thread
johnpryan marked this conversation as resolved.
Resolved

if (method == _postMethod) {
final requestHeader = request.headers[devToolsRequestHeader];
if (requestHeader == null || requestHeader.trim().isEmpty) {
return api.forbidden('Missing $devToolsRequestHeader header');
}
}

if (disableOriginCheck) {
return null;
}

final hostHeaders = request.headersAll[HttpHeaders.hostHeader];
if (hostHeaders != null && hostHeaders.length > 1) {
return api.forbidden('Invalid Host header');
}
final hostHeader = request.headers[HttpHeaders.hostHeader];
final host = hostHeader != null
? _extractHostFromHeader(hostHeader)
: request.requestedUri.host;
if (host == null ||
!_isAllowedHost(host, allowedOrigins) ||
!_isAllowedHost(request.requestedUri.host, allowedOrigins)) {
return api.forbidden('Invalid Host header');
}

final originHeaders = request.headersAll[_originHeader];
if (originHeaders != null && originHeaders.length > 1) {
return api.forbidden('Invalid Origin header');
}
final originHeader = request.headers[_originHeader];
if (originHeader != null) {
final originUri = Uri.tryParse(originHeader.trim());
if (originUri == null ||
!_isHttpOrHttps(originUri) ||
originUri.userInfo.isNotEmpty ||
originUri.host.isEmpty ||
originUri.path.isNotEmpty ||
originUri.hasQuery ||
originUri.hasFragment ||
!_isAllowedOrigin(originUri, allowedOrigins)) {
return api.forbidden('Invalid Origin header');
}
}
Comment thread
johnpryan marked this conversation as resolved.
Resolved

final secFetchSite = request.headers[_secFetchSiteHeader]
?.trim()
.toLowerCase();
if (secFetchSite != null) {
final allowedSites = originHeader != null
? _allowedSecFetchSitesWithOrigin
: _allowedSecFetchSitesWithoutOrigin;
if (!allowedSites.contains(secFetchSite)) {
return api.forbidden('Cross-site requests are not allowed');
}
}
Comment thread
johnpryan marked this conversation as resolved.
Resolved

return null;
}

/// Extracts the host component from an HTTP `Host` header value, or returns
/// `null` if the header is malformed.
static String? _extractHostFromHeader(String hostHeader) {
final trimmed = hostHeader.trim();
if (trimmed.isEmpty) return null;
if (trimmed == '::1') return '::1';
final parsed = Uri.tryParse('http://$trimmed');
if (parsed == null ||
parsed.userInfo.isNotEmpty ||
parsed.path.isNotEmpty ||
parsed.hasQuery ||
parsed.hasFragment ||
parsed.host.isEmpty) {
return null;
}
return parsed.host;
}

/// Returns whether [uri] uses the `http` or `https` scheme.
static bool _isHttpOrHttps(Uri uri) {
return uri.isScheme('http') || uri.isScheme('https');
}

/// Returns whether [host] is a loopback host or matches the host of an entry
/// in [allowedOrigins].
static bool _isAllowedHost(String host, List<Uri> allowedOrigins) {
if (host.isEmpty) return false;
if (_isLoopbackHost(host)) return true;

for (final allowedOrigin in allowedOrigins) {
if (allowedOrigin.host.isEmpty) continue;
if (_hostsMatch(allowedOrigin.host, host)) return true;
}
return false;
}

/// Returns whether [originUri] is a loopback origin or matches the scheme,
/// host, and port of an HTTP(S) URI in [allowedOrigins].
static bool _isAllowedOrigin(Uri originUri, List<Uri> allowedOrigins) {
if (originUri.host.isEmpty) return false;
if (_isLoopbackHost(originUri.host)) return true;

for (final allowedOrigin in allowedOrigins) {
if (!_isHttpOrHttps(allowedOrigin)) continue;
if (allowedOrigin.host.isEmpty) continue;
if (originUri.scheme != allowedOrigin.scheme) continue;
if (originUri.port != allowedOrigin.port) continue;
if (_hostsMatch(allowedOrigin.host, originUri.host)) return true;
}
return false;
}

/// Returns whether [a] and [b] refer to the same hostname or IP address.
static bool _hostsMatch(String a, String b) {
if (a.toLowerCase() == b.toLowerCase()) return true;
final addrA = InternetAddress.tryParse(a);
final addrB = InternetAddress.tryParse(b);
if (addrA == null || addrB == null) return false;
return const ListEquality<int>().equals(addrA.rawAddress, addrB.rawAddress);
}

/// Returns whether [host] is a loopback host
/// (`localhost`, `127.x.x.x`, or `::1`).
static bool _isLoopbackHost(String host) {
if (host.toLowerCase() == 'localhost') return true;
final address = InternetAddress.tryParse(host);
return address?.isLoopback ?? false;
}

/// Handles all requests.
///
/// To override an API call, pass in a subclass of [ServerApi].
///
/// Set [disableOriginCheck] to `true` to bypass `Host`, `Origin`, and
/// `Sec-Fetch-Site` validation while still enforcing HTTP method and
/// [devToolsRequestHeader] checks.
///
/// Pass [allowedOrigins] to permit requests from specific non-loopback
/// origins (and their corresponding `Host` headers) in addition to loopback
/// hosts and origins.
static FutureOr<shelf.Response> handle(
shelf.Request request, {
required ExtensionsManager extensionsManager,
required DeeplinkManager deeplinkManager,
ServerApi? api,
DtdInfo? dtd,
bool disableOriginCheck = false,
List<Uri> allowedOrigins = const <Uri>[],
}) {
api ??= ServerApi();
final validationErrorResponse = _validateRequest(
request,
api,
disableOriginCheck: disableOriginCheck,
allowedOrigins: allowedOrigins,
);
if (validationErrorResponse != null) {
return validationErrorResponse;
}
Comment thread
johnpryan marked this conversation as resolved.
Resolved
if (request.method.toUpperCase() == _optionsMethod) {
// Respond to CORS preflight requests (e.g. from `debug_devtools_server`
// running on another loopback port). `Access-Control-Allow-Origin` is
// intentionally omitted here and controlled by the server's `--disable-cors`
// configuration.
return shelf.Response(
HttpStatus.noContent,
headers: const {
HttpHeaders.accessControlAllowMethodsHeader:
'$_postMethod, $_optionsMethod',
HttpHeaders.accessControlAllowHeadersHeader: devToolsRequestHeader,
},
);
}
final queryParams = request.requestedUri.queryParameters;
switch (request.url.path) {
case apiNotifyForVmServiceConnection:
Expand Down
15 changes: 10 additions & 5 deletions packages/devtools_shared/test/server/deeplink_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ void main() {
test('handle deeplink api ${DeeplinkApi.androidBuildVariants}', () async {
const expectedRootPath = '/abc';
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
Expand All @@ -27,6 +27,7 @@ void main() {
DeeplinkApi.deeplinkRootPathPropertyName: expectedRootPath,
},
),
headers: const {devToolsRequestHeader: '1'},
);
final fakeManager = FakeDeeplinkManager();
fakeManager.responseForGetAndroidBuildVariants = <String, String>{
Expand All @@ -46,12 +47,13 @@ void main() {
'handle deeplink api ${DeeplinkApi.androidBuildVariants} returns bad request if no root path',
() async {
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
path: DeeplinkApi.androidBuildVariants,
),
headers: const {devToolsRequestHeader: '1'},
);
final response = await ServerApi.handle(
request,
Expand All @@ -67,7 +69,7 @@ void main() {
const buildVariant = 'buildVariant';
const someMessage = 'some message';
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
Expand All @@ -77,6 +79,7 @@ void main() {
DeeplinkApi.androidBuildVariantPropertyName: buildVariant,
},
),
headers: const {devToolsRequestHeader: '1'},
);
final fakeManager = FakeDeeplinkManager();
fakeManager.responseForGetAndroidAppLinkSettings = <String, String>{
Expand All @@ -97,7 +100,7 @@ void main() {
const expectedRootPath = '/abc';
const someMessage = 'some message';
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
Expand All @@ -106,6 +109,7 @@ void main() {
DeeplinkApi.deeplinkRootPathPropertyName: expectedRootPath,
},
),
headers: const {devToolsRequestHeader: '1'},
);
final fakeManager = FakeDeeplinkManager();
fakeManager.responseForGetIosBuildOptions = <String, String>{
Expand All @@ -129,7 +133,7 @@ void main() {
const target = 'target';
const someMessage = 'some message';
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
Expand All @@ -140,6 +144,7 @@ void main() {
DeeplinkApi.xcodeTargetPropertyName: target,
},
),
headers: const {devToolsRequestHeader: '1'},
);
final fakeManager = FakeDeeplinkManager();
fakeManager.responseForGetIosUniversalLinkSettings = <String, String>{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ void main() {
: null,
},
),
headers: const {devToolsRequestHeader: '1'},
);
return await ServerApi.handle(
request,
Expand Down Expand Up @@ -691,6 +692,7 @@ void main() {
ExtensionsApi.enabledStatePropertyName: enable.toString(),
},
),
headers: const {devToolsRequestHeader: '1'},
);
return await ServerApi.handle(
request,
Expand Down
3 changes: 2 additions & 1 deletion packages/devtools_shared/test/server/dtd_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ void main() {
test('handle ${DtdApi.apiGetDtdUri} succeeds', () async {
final dtdUri = Uri.parse('ws://dtd/uri');
final request = Request(
'get',
'post',
Uri(scheme: 'https', host: 'localhost', path: DtdApi.apiGetDtdUri),
headers: const {devToolsRequestHeader: '1'},
);
final response = await ServerApi.handle(
request,
Expand Down
Loading
Loading