fix: validate devtoolsOptionsUri in extensionEnabledState handler (#9834)
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 5ead76e..14cf2ab 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -23,6 +23,10 @@
the `~/.flutter-devtools/` directory and cannot resolve to arbitrary files
on disk. -
[#9844](https://github.com/flutter/devtools/pull/9844)
+* Validated the `devtoolsOptionsUri` parameter in the extension enabled-state
+ handler so it must be a `file:` URI named `devtools_options.yaml`, preventing
+ the DevTools server from writing to arbitrary file paths. -
+ [#9834](https://github.com/flutter/devtools/pull/9834)
## Inspector updates
diff --git a/packages/devtools_shared/CHANGELOG.md b/packages/devtools_shared/CHANGELOG.md
index cc2e5ae..eaf9a53 100644
--- a/packages/devtools_shared/CHANGELOG.md
+++ b/packages/devtools_shared/CHANGELOG.md
@@ -3,7 +3,11 @@
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
-->
-# 13.0.2-wip
+
+# 13.0.2
+* Validate the `devtoolsOptionsUri` query parameter in the extension enabled
+ state handler so it must be a `file:` URI named `devtools_options.yaml`,
+ preventing arbitrary file writes by the DevTools server process.
* The minimum Dart SDK version is bumped to 3.11.0.
* The minimum Flutter SDK version is bumped to 3.41.0.
diff --git a/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart b/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart
index c01b8cc..0ee24d9 100644
--- a/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart
+++ b/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart
@@ -81,6 +81,27 @@
final devtoolsOptionsFileUriString =
queryParams[ExtensionsApi.devtoolsOptionsUriPropertyName]!;
final devtoolsOptionsFileUri = Uri.parse(devtoolsOptionsFileUriString);
+
+ // Validate that the URI is a local file URI whose file name is exactly
+ // 'devtools_options.yaml'. Accepting arbitrary URIs from the query string
+ // would allow an untrusted caller to create or overwrite any file writable
+ // by the DevTools server process. Resolving the name through
+ // `Uri.toFilePath()` + `p.basename` handles both '/' and '\' path
+ // separators, so the check holds for Windows file URIs as well. Requiring
+ // an empty host rejects UNC paths (e.g. `file://server/share/...`) and
+ // keeps `toFilePath()` from throwing on a non-local authority.
+ final isFileUri = devtoolsOptionsFileUri.scheme == 'file' &&
+ devtoolsOptionsFileUri.host.isEmpty;
+ final fileName = isFileUri
+ ? p.basename(devtoolsOptionsFileUri.toFilePath())
+ : '';
+ if (!isFileUri || fileName != 'devtools_options.yaml') {
+ return api.badRequest(
+ 'Invalid devtoolsOptionsUri: must be a file: URI named '
+ "'devtools_options.yaml'.",
+ );
+ }
+
final extensionName = queryParams[ExtensionsApi.extensionNamePropertyName]!;
final activate = queryParams[ExtensionsApi.enabledStatePropertyName];
diff --git a/packages/devtools_shared/lib/src/server/server_api.dart b/packages/devtools_shared/lib/src/server/server_api.dart
index 3484477..cc96620 100644
--- a/packages/devtools_shared/lib/src/server/server_api.dart
+++ b/packages/devtools_shared/lib/src/server/server_api.dart
@@ -11,6 +11,7 @@
import 'package:collection/collection.dart';
import 'package:dtd/dtd.dart';
import 'package:meta/meta.dart';
+import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart' as shelf;
import 'package:vm_service/vm_service.dart';
diff --git a/packages/devtools_shared/pubspec.yaml b/packages/devtools_shared/pubspec.yaml
index 7d8e429..332adaa 100644
--- a/packages/devtools_shared/pubspec.yaml
+++ b/packages/devtools_shared/pubspec.yaml
@@ -4,7 +4,7 @@
name: devtools_shared
description: Package of shared Dart structures between devtools_app, dds, and other tools.
-version: 13.0.2-dev
+version: 13.0.2
repository: https://github.com/flutter/devtools/tree/master/packages/devtools_shared
diff --git a/packages/devtools_shared/test/server/devtools_extensions_api_test.dart b/packages/devtools_shared/test/server/devtools_extensions_api_test.dart
index 185b7ff..af0530e 100644
--- a/packages/devtools_shared/test/server/devtools_extensions_api_test.dart
+++ b/packages/devtools_shared/test/server/devtools_extensions_api_test.dart
@@ -144,19 +144,24 @@
group(ExtensionsApi.apiExtensionEnabledState, () {
late File optionsFile;
- late final optionsFileUriString = p.posix.join(
- extensionTestManager.runtimeAppRoot,
- devtoolsOptionsFileName,
- );
+ late String optionsFileUriString;
setUp(() async {
await initializeTestDirectory();
+ // Recompute per test: each test gets a fresh temp directory, so this
+ // must not be cached across tests (otherwise it would point at a
+ // previous test's directory).
+ optionsFileUriString = p.posix.join(
+ extensionTestManager.runtimeAppRoot,
+ devtoolsOptionsFileName,
+ );
optionsFile = File.fromUri(Uri.parse(optionsFileUriString));
});
Future<Response> sendEnabledStateRequest({
required String extensionName,
bool? enable,
+ String? optionsUriOverride,
}) async {
final request = Request(
'post',
@@ -165,7 +170,8 @@
host: 'localhost',
path: ExtensionsApi.apiExtensionEnabledState,
queryParameters: {
- ExtensionsApi.devtoolsOptionsUriPropertyName: optionsFileUriString,
+ ExtensionsApi.devtoolsOptionsUriPropertyName:
+ optionsUriOverride ?? optionsFileUriString,
ExtensionsApi.extensionNamePropertyName: extensionName,
if (enable != null)
ExtensionsApi.enabledStatePropertyName: enable.toString(),
@@ -179,6 +185,37 @@
);
}
+ test('rejects a devtoolsOptionsUri that is not a devtools_options.yaml '
+ 'file', () async {
+ await serveExtensions(extensionsManager);
+ const invalidUris = [
+ // Wrong file name.
+ 'file:///tmp/evil.txt',
+ 'file:///tmp/devtools_options.yaml.bak',
+ // Non-file scheme.
+ 'https://evil.example.com/devtools_options.yaml',
+ // UNC path / non-empty host.
+ 'file://remotehost/share/devtools_options.yaml',
+ ];
+ for (final invalidUri in invalidUris) {
+ final response = await sendEnabledStateRequest(
+ extensionName: 'drift',
+ optionsUriOverride: invalidUri,
+ );
+ expect(
+ response.statusCode,
+ HttpStatus.badRequest,
+ reason: 'expected $invalidUri to be rejected',
+ );
+ }
+ });
+
+ test('accepts a valid devtools_options.yaml file: URI', () async {
+ await serveExtensions(extensionsManager);
+ final response = await sendEnabledStateRequest(extensionName: 'drift');
+ expect(response.statusCode, HttpStatus.ok);
+ });
+
test('options file does not exist until first acesss', () async {
await serveExtensions(extensionsManager);
expect(optionsFile.existsSync(), isFalse);