Skip to content

Commit a53445b

Browse files
authored
fix: validate devtoolsOptionsUri in extensionEnabledState handler (#9834)
1 parent 20b6936 commit a53445b

6 files changed

Lines changed: 74 additions & 7 deletions

File tree

packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ To learn more about DevTools, check out the
2323
the `~/.flutter-devtools/` directory and cannot resolve to arbitrary files
2424
on disk. -
2525
[#9844](https://github.com/flutter/devtools/pull/9844)
26+
* Validated the `devtoolsOptionsUri` parameter in the extension enabled-state
27+
handler so it must be a `file:` URI named `devtools_options.yaml`, preventing
28+
the DevTools server from writing to arbitrary file paths. -
29+
[#9834](https://github.com/flutter/devtools/pull/9834)
2630

2731
## Inspector updates
2832

packages/devtools_shared/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ Copyright 2025 The Flutter Authors
33
Use of this source code is governed by a BSD-style license that can be
44
found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
55
-->
6-
# 13.0.2-wip
6+
7+
# 13.0.2
8+
* Validate the `devtoolsOptionsUri` query parameter in the extension enabled
9+
state handler so it must be a `file:` URI named `devtools_options.yaml`,
10+
preventing arbitrary file writes by the DevTools server process.
711
* The minimum Dart SDK version is bumped to 3.11.0.
812
* The minimum Flutter SDK version is bumped to 3.41.0.
913

packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ extension _ExtensionsApiHandler on Never {
8181
final devtoolsOptionsFileUriString =
8282
queryParams[ExtensionsApi.devtoolsOptionsUriPropertyName]!;
8383
final devtoolsOptionsFileUri = Uri.parse(devtoolsOptionsFileUriString);
84+
85+
// Validate that the URI is a local file URI whose file name is exactly
86+
// 'devtools_options.yaml'. Accepting arbitrary URIs from the query string
87+
// would allow an untrusted caller to create or overwrite any file writable
88+
// by the DevTools server process. Resolving the name through
89+
// `Uri.toFilePath()` + `p.basename` handles both '/' and '\' path
90+
// separators, so the check holds for Windows file URIs as well. Requiring
91+
// an empty host rejects UNC paths (e.g. `file://server/share/...`) and
92+
// keeps `toFilePath()` from throwing on a non-local authority.
93+
final isFileUri = devtoolsOptionsFileUri.scheme == 'file' &&
94+
devtoolsOptionsFileUri.host.isEmpty;
95+
final fileName = isFileUri
96+
? p.basename(devtoolsOptionsFileUri.toFilePath())
97+
: '';
98+
if (!isFileUri || fileName != 'devtools_options.yaml') {
99+
return api.badRequest(
100+
'Invalid devtoolsOptionsUri: must be a file: URI named '
101+
"'devtools_options.yaml'.",
102+
);
103+
}
104+
84105
final extensionName = queryParams[ExtensionsApi.extensionNamePropertyName]!;
85106

86107
final activate = queryParams[ExtensionsApi.enabledStatePropertyName];

packages/devtools_shared/lib/src/server/server_api.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'dart:io';
1111
import 'package:collection/collection.dart';
1212
import 'package:dtd/dtd.dart';
1313
import 'package:meta/meta.dart';
14+
import 'package:path/path.dart' as p;
1415
import 'package:shelf/shelf.dart' as shelf;
1516
import 'package:vm_service/vm_service.dart';
1617

packages/devtools_shared/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
name: devtools_shared
55
description: Package of shared Dart structures between devtools_app, dds, and other tools.
66

7-
version: 13.0.2-dev
7+
version: 13.0.2
88

99
repository: https://github.com/flutter/devtools/tree/master/packages/devtools_shared
1010

packages/devtools_shared/test/server/devtools_extensions_api_test.dart

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,24 @@ void main() {
144144

145145
group(ExtensionsApi.apiExtensionEnabledState, () {
146146
late File optionsFile;
147-
late final optionsFileUriString = p.posix.join(
148-
extensionTestManager.runtimeAppRoot,
149-
devtoolsOptionsFileName,
150-
);
147+
late String optionsFileUriString;
151148

152149
setUp(() async {
153150
await initializeTestDirectory();
151+
// Recompute per test: each test gets a fresh temp directory, so this
152+
// must not be cached across tests (otherwise it would point at a
153+
// previous test's directory).
154+
optionsFileUriString = p.posix.join(
155+
extensionTestManager.runtimeAppRoot,
156+
devtoolsOptionsFileName,
157+
);
154158
optionsFile = File.fromUri(Uri.parse(optionsFileUriString));
155159
});
156160

157161
Future<Response> sendEnabledStateRequest({
158162
required String extensionName,
159163
bool? enable,
164+
String? optionsUriOverride,
160165
}) async {
161166
final request = Request(
162167
'post',
@@ -165,7 +170,8 @@ void main() {
165170
host: 'localhost',
166171
path: ExtensionsApi.apiExtensionEnabledState,
167172
queryParameters: {
168-
ExtensionsApi.devtoolsOptionsUriPropertyName: optionsFileUriString,
173+
ExtensionsApi.devtoolsOptionsUriPropertyName:
174+
optionsUriOverride ?? optionsFileUriString,
169175
ExtensionsApi.extensionNamePropertyName: extensionName,
170176
if (enable != null)
171177
ExtensionsApi.enabledStatePropertyName: enable.toString(),
@@ -179,6 +185,37 @@ void main() {
179185
);
180186
}
181187

188+
test('rejects a devtoolsOptionsUri that is not a devtools_options.yaml '
189+
'file', () async {
190+
await serveExtensions(extensionsManager);
191+
const invalidUris = [
192+
// Wrong file name.
193+
'file:///tmp/evil.txt',
194+
'file:///tmp/devtools_options.yaml.bak',
195+
// Non-file scheme.
196+
'https://evil.example.com/devtools_options.yaml',
197+
// UNC path / non-empty host.
198+
'file://remotehost/share/devtools_options.yaml',
199+
];
200+
for (final invalidUri in invalidUris) {
201+
final response = await sendEnabledStateRequest(
202+
extensionName: 'drift',
203+
optionsUriOverride: invalidUri,
204+
);
205+
expect(
206+
response.statusCode,
207+
HttpStatus.badRequest,
208+
reason: 'expected $invalidUri to be rejected',
209+
);
210+
}
211+
});
212+
213+
test('accepts a valid devtools_options.yaml file: URI', () async {
214+
await serveExtensions(extensionsManager);
215+
final response = await sendEnabledStateRequest(extensionName: 'drift');
216+
expect(response.statusCode, HttpStatus.ok);
217+
});
218+
182219
test('options file does not exist until first acesss', () async {
183220
await serveExtensions(extensionsManager);
184221
expect(optionsFile.existsSync(), isFalse);

0 commit comments

Comments
 (0)