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
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 @@ -21,6 +21,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` or `OPTIONS`
methods and loopback `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
4 changes: 4 additions & 0 deletions packages/devtools_shared/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ 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` (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.
* Add `devToolsRequestHeader` constant (`X-DevTools-Request`).

# 14.0.0

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
109 changes: 109 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,11 +44,102 @@ 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 _crossSiteSecFetchSite = 'cross-site';
static const _allowedSecFetchSites = {'same-origin', 'same-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.
///
/// 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,
) {
final method = request.method.toUpperCase();
if (method != _postMethod && method != _optionsMethod) {
return shelf.Response(
HttpStatus.methodNotAllowed,
body: 'Method Not Allowed: ${request.method}',
);
}

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 ||
!_isLoopbackHost(host) ||
!_isLoopbackHost(request.requestedUri.host)) {
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 ||
originUri.userInfo.isNotEmpty ||
!_isLoopbackHost(originUri.host)) {
return api.forbidden('Invalid Origin header');
}
}

final secFetchSite = request.headers[_secFetchSiteHeader]
?.trim()
.toLowerCase();
// Allow `cross-site` when a validated loopback `Origin` header is present
// (e.g. `http://localhost:<port>` calling `http://127.0.0.1:<port>` in
// `debug_devtools_server` mode).
if (secFetchSite != null &&
!_allowedSecFetchSites.contains(secFetchSite) &&
!(secFetchSite == _crossSiteSecFetchSite && originHeader != null)) {
return api.forbidden('Cross-site requests are not allowed');
}

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 [host] is a loopback host
/// (`localhost`, `127.x.x.x`, or `::1`).
static bool _isLoopbackHost(String host) =>
host == 'localhost' ||
(InternetAddress.tryParse(host)?.isLoopback ?? false);

/// Handles all requests.
///
/// To override an API call, pass in a subclass of [ServerApi].
Expand All @@ -60,6 +151,24 @@ class ServerApi {
DtdInfo? dtd,
}) {
api ??= ServerApi();
final validationErrorResponse = _validateRequest(request, api);
if (validationErrorResponse != null) {
return validationErrorResponse;
}
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
10 changes: 5 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 Down Expand Up @@ -46,7 +46,7 @@ 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',
Expand All @@ -67,7 +67,7 @@ void main() {
const buildVariant = 'buildVariant';
const someMessage = 'some message';
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
Expand Down Expand Up @@ -97,7 +97,7 @@ void main() {
const expectedRootPath = '/abc';
const someMessage = 'some message';
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
Expand Down Expand Up @@ -129,7 +129,7 @@ void main() {
const target = 'target';
const someMessage = 'some message';
final request = Request(
'get',
'post',
Uri(
scheme: 'https',
host: 'localhost',
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools_shared/test/server/dtd_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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),
);
final response = await ServerApi.handle(
Expand Down
Loading
Loading