diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 2cd2c928fe0..de0173466f3 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -15,7 +15,10 @@ To learn more about DevTools, check out the ## General updates -TODO: Remove this section if there are not any updates. +* Rejected absolute paths in DevTools server file reads so they stay within + the `~/.flutter-devtools/` directory and cannot resolve to arbitrary files + on disk. - + [#9844](https://github.com/flutter/devtools/pull/9844) ## Inspector updates diff --git a/packages/devtools_shared/lib/src/server/file_system.dart b/packages/devtools_shared/lib/src/server/file_system.dart index 58dc6bcf281..1f79c0fbdb4 100644 --- a/packages/devtools_shared/lib/src/server/file_system.dart +++ b/packages/devtools_shared/lib/src/server/file_system.dart @@ -45,14 +45,24 @@ extension LocalFileSystem on Never { /// /// Only files within ~/.flutter-devtools/ can be accessed. static File? devToolsFileFromPath(String pathFromDevToolsDir) { - if (pathFromDevToolsDir.contains('..')) { + if (pathFromDevToolsDir.contains('..') || + path.isAbsolute(pathFromDevToolsDir)) { // The passed in path should not be able to walk up the directory tree - // outside of the ~/.flutter-devtools/ directory. + // outside of the ~/.flutter-devtools/ directory. It must also not be an + // absolute path: path.join() discards the base directory when its second + // argument is absolute, which would otherwise allow reading an arbitrary + // file on disk (e.g. an absolute path to a credentials .json file). return null; } ensureDevToolsDirectory(); - final file = File(path.join(devToolsDir(), pathFromDevToolsDir)); + final devToolsDirPath = devToolsDir(); + final file = File(path.join(devToolsDirPath, pathFromDevToolsDir)); + // Defense in depth: ensure the resolved path is actually contained within + // the DevTools directory. + if (!path.isWithin(devToolsDirPath, file.path)) { + return null; + } if (!file.existsSync()) { return null; } diff --git a/packages/devtools_shared/test/server/file_system_test.dart b/packages/devtools_shared/test/server/file_system_test.dart new file mode 100644 index 00000000000..78069ec1733 --- /dev/null +++ b/packages/devtools_shared/test/server/file_system_test.dart @@ -0,0 +1,36 @@ +// Copyright 2026 The Flutter Authors +// 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. + +import 'package:devtools_shared/src/server/file_system.dart'; +import 'package:test/test.dart'; + +void main() { + group('LocalFileSystem.devToolsFileFromPath path validation', () { + // These inputs must be rejected before any filesystem access so that reads + // stay confined to the ~/.flutter-devtools/ directory. + + test('rejects absolute paths', () { + // path.join() discards the base directory when its second argument is + // absolute, so an absolute path would otherwise escape the DevTools + // directory and read an arbitrary file on disk. + expect(LocalFileSystem.devToolsFileFromPath('/etc/passwd'), isNull); + expect( + LocalFileSystem.devToolsFileFromPath('/absolute/path/to/file.json'), + isNull, + ); + }); + + test('rejects paths containing ".."', () { + expect(LocalFileSystem.devToolsFileFromPath('..'), isNull); + expect( + LocalFileSystem.devToolsFileFromPath('../../../etc/passwd'), + isNull, + ); + expect( + LocalFileSystem.devToolsFileFromPath('subdir/../../escape.json'), + isNull, + ); + }); + }); +}