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);