Repository navigation
[flutter_tools] Contain web asset server path resolution to its search roots - #194064
auto-submit[bot] merged 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces path traversal and absolute URI resolution protections in ReleaseAssetServer and WebAssetServer by implementing isRelativeRequestPath and resolveRequestPathUnder helper functions. These helpers ensure that untrusted request paths are relative and resolve strictly within the designated trusted root directories, preventing access to files outside the project. Additionally, comprehensive unit tests are added to verify these security boundaries on both POSIX and Windows systems. I have no feedback to provide as there are no review comments.
mdebbar
left a comment
There was a problem hiding this comment.
Thanks for the contribution!
This looks good to me. I just have some minor suggestions to improve the comments and tests.
| // | ||
| // An empty set relies on the request path staying below its root, which | ||
| // `resolveRequestPathUnder` enforces for every root listed below. |
There was a problem hiding this comment.
This reads as part of the _sourceMapExtensions comment, but it's about the empty set in _searchPaths. Let's move it there:
| // | |
| // An empty set relies on the request path staying below its root, which | |
| // `resolveRequestPathUnder` enforces for every root listed below. |
There was a problem hiding this comment.
Done, moved it to the _searchPaths comment where the empty set is described.
| // A dot segment would resolve outside the package root, which package | ||
| // resolution does not guard against on its own. |
There was a problem hiding this comment.
I don't think a dot segment can get outside the package root here: Uri collapses them, and once they climb past the package name PackageConfig.resolve throws instead.
| // A dot segment would resolve outside the package root, which package | |
| // resolution does not guard against on its own. | |
| // A dot segment that climbs past the package name makes | |
| // `PackageConfig.resolve` throw. |
There was a problem hiding this comment.
You're right, thanks. Applied your wording, and updated the commit message and PR description to match.
| '../secret.txt', | ||
| '../secret.dart', |
There was a problem hiding this comment.
Can we add a test like this to dartSourceContents too?
expect(await webAssetServer.dartSourceContents('../secret.dart'), isNull);
expect(
await webAssetServer.dartSourceContents('packages/flutter_tools/../../secret.dart'),
isNull,
);There was a problem hiding this comment.
Added both to WebAssetServer does not serve files outside of the project. The first one fails without the lib/ change.
…h roots The web asset servers resolve the request path against each of their search roots with `Uri.resolve`. That treats the request path as a URI *reference*, not as a path: a reference carrying a scheme, an authority or an absolute path replaces the base instead of resolving below it, so the result is not necessarily inside the root that was being searched. Add two helpers to `web_server_utilities.dart`: * `isRelativeRequestPath` rejects a request path that is not a relative reference, i.e. one with a scheme or a Windows drive letter in its first segment, an authority, an absolute path, a backslash or a NUL. * `resolveRequestPathUnder` resolves against a given root and returns the result only when it lands inside that root, compared with `path.isWithin` so the comparison is per-component and case-insensitive on Windows. Every `resolve()` call in `ReleaseAssetServer.handle` and `WebAssetServer.handleRequest` now goes through the helper, so each search root keeps its own boundary: the canvaskit fallback, the built asset fallback, the project `web` directory, and the three roots consulted by `_resolveDartFile`. `.` and `..` segments are deliberately still accepted and handled by the containment check, so a request path that normalizes back inside the root keeps working. Dot segments are rejected before package resolution, where one that climbs past the package name makes `PackageConfig.resolve` throw. This also keeps an `UnsupportedError` from escaping the handler: `GET /..%2F..%2F..%2Ftmp/foo` currently throws `Illegal path character` out of `toFilePath` instead of being answered. The existing "does not serve files outside of the project" test is extended with the reference shapes that resolve outside a search root, and the debug handler gains the equivalent test, including `dartSourceContents`.
348e577 to
b1149ce
Compare
mdebbar
left a comment
There was a problem hiding this comment.
I need to find another reviewer to approve this so we can merge it.
|
Thanks for the review and approval! I appreciate it. Looking forward to the
second review.
…On Fri 23 Oct 2026 at 18:54, Mouad Debbar ***@***.***> wrote:
***@***.**** approved this pull request.
I need to find another reviewer to approve this so we can merge it.
—
Reply to this email directly, view it on GitHub
<#194064?email_source=notifications&email_token=BDB3F6ZBHRCAYWU7SMWWNZD5VOENNA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTKNRTGE3DONRYGA22M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#pullrequestreview-5631676805>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/BDB3F6ZTOSINURZ5PYHI3QT5VOENNAVCNFSNUABEKJSXA33TNF2G64TZHMZTCNZZGI4DENB3JFZXG5LFHM2TONZUGYZTINZQGKQXMAQ>
.
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
| if (segments.first == 'packages' && !hasDotSegment) { | ||
| final Uri? filePath = _packages.resolve( | ||
| Uri(scheme: 'package', pathSegments: segments.skip(1)), | ||
| ); |
There was a problem hiding this comment.
There are two edge cases with package resolution here that bypass containment or cause unhandled exceptions:
-
Path traversal / search root escape via double slash:
If a request comes in forpackages/<package>//<absolute_path>(e.g.packages/flutter_tools//home/user/secret.dartor on Windowspackages/flutter_tools//C:/Windows/win.ini),isRelativeRequestPathreturns true, butsegments.skip(1)contains an empty segment, producingpackage:flutter_tools//home/user/secret.dart. When_packages.resolveresolves that URI,package_configstrips the package name and resolves/home/user/secret.dartagainstpackage.packageUriRoot. Because of the leading slash,Uri.resolvetreats it as an absolute path reference and resolves tofile:///home/user/secret.darton root, completely bypassing containment. -
Unhandled
ArgumentErroron short paths:
Ifpathispackages/foo.dart,segments.skip(1)is['foo.dart'].Uri(scheme: 'package', pathSegments: ['foo.dart'])constructspackage:foo.dartwithout a trailing path segment, causing_packages.resolveto throw an unhandledArgumentError("Package URIs must start with the package name followed by a '/'"), crashing the request with an HTTP 500 instead of returning 404.
We can address both by validating the segment count, looking up the package directly, and resolving the remaining path with resolveRequestPathUnder:
if (segments.length >= 3 && segments.first == 'packages') {
final Package? package = _packages[segments[1]];
if (package != null) {
final String relativePackagePath = segments.skip(2).join('/');
final Uri? packageFileUri = resolveRequestPathUnder(
package.packageUriRoot,
relativePackagePath,
fileSystem: fs,
windows: platform.isWindows,
);
if (packageFileUri != null) {
final File packageFile = fs.file(packageFileUri);
if (packageFile.existsSync()) {
return packageFile;
}
}
}
}This guarantees containment below the package's root and avoids calling _packages.resolve with arbitrary URI segments.
There was a problem hiding this comment.
Thanks, good catches. I reproduced both with tests before changing anything, then applied your suggestion in 63978ed. It also drops the dot-segment check, since containment below the package root now handles those like the other roots. The new cases are in WebAssetServer does not serve files outside of the project, plus a positive case for a dot segment that stays inside the package.
| } else { | ||
| for (final (Uri uri, Set<String> allowedExtensions) in _searchPaths()) { | ||
| final Uri potential = uri.resolve(requestPath); | ||
| final Uri? potential = resolveRequestPathUnder( |
There was a problem hiding this comment.
As a minor optimization, we could check allowedExtensions against _fileSystem.path.extension(requestPath) before calling resolveRequestPathUnder. Since _flutterRoot and _currentDirectory are restricted to source maps (.dart, .map), checking the extension upfront allows us to skip URI resolution and toFilePath conversions on every regular asset/html request.
There was a problem hiding this comment.
Done in 63978ed, the extension is checked against the request path before resolving. It matches the old check on the resolved path: resolution keeps the last segment unless it is . or .., and those have no extension.
`_resolveDartFile` no longer builds a `package:` URI from the request segments. It looks the package up by name and resolves the rest of the path below that package's `packageUriRoot` with `resolveRequestPathUnder`, like the other search roots. An absolute path after the package name (`packages/<package>//<path>`) can no longer replace the package root, and a request with nothing after the package name (`packages/foo.dart`) is answered instead of throwing an `ArgumentError` out of `PackageConfig.resolve`. This replaces the dot-segment check: containment handles dot segments here the same way it does for the other roots. `ReleaseAssetServer` now checks a root's allowed extensions against the request path before resolving it, so regular asset requests skip resolution for the source-map-only roots.
|
Thanks for the work here! 🙏 |
…s search roots (#195201) Cherry-pick of #194064 to beta (`flutter-3.50-candidate.0`). ### Impact Description: Tightens how the web development server in `flutter_tools` maps request paths to files, so every lookup stays within the directory it is meant to serve from. It also stops some malformed request paths from throwing out of the request handler. This only affects development with `flutter run` on web, not deployed apps. ### Changelog Description: [#194064](#194064) When running a web app with `flutter run`, the development server resolves some request paths incorrectly. ### Workaround: None. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: In `packages/flutter_tools`, run `test/general.shard/web/devfs_web_test.dart` and `test/general.shard/web/web_asset_server_test.dart`. All tests pass on this branch, and the new tests fail without the fix. Note: `web_asset_server.dart` needed a small manual merge because #194053 isn't on the 3.50 branch. The logic is the same as on master.
…h roots (flutter#194064) `flutter_tools`' web asset servers resolve the incoming request path against each of their search roots with `Uri.resolve`. That treats the request path as a URI *reference* rather than as a path, and a reference carrying a scheme, an authority or an absolute path replaces the base instead of resolving below it. The result of a lookup is therefore not guaranteed to be inside the root that was being searched, and nothing downstream checks that it is. This PR makes the containment explicit instead of relying on the shape of the request path. Two helpers are added to `web_server_utilities.dart`: * `isRelativeRequestPath` rejects a request path that is not a relative reference: one with a scheme or a Windows drive letter in its first segment, an authority, an absolute path, a backslash, or a NUL. * `resolveRequestPathUnder` resolves against a given root and returns the result only when it lands inside that root, compared with `path.isWithin` so the comparison is per-component and case-insensitive on Windows. Every `resolve()` call in `ReleaseAssetServer.handle` and `WebAssetServer.handleRequest` now goes through the helper, so each search root keeps its own boundary: the canvaskit fallback, the built asset fallback, the project `web` directory, and the roots consulted by `_resolveDartFile`. `.` and `..` segments are deliberately still accepted and handled by the containment check, so a request path that normalizes back inside the root keeps working. A `/packages/<package>/<path>` request is resolved the same way below that package's `packageUriRoot`, looked up by name, instead of through a `package:` URI built from the request segments. `ReleaseAssetServer` checks a root's allowed extensions against the request path before resolving it, so regular asset requests skip resolution for the source-map-only roots. This also keeps exceptions from escaping the handler. On `master` today, `GET /..%2F..%2F..%2Ftmp/foo` throws `UnsupportedError: Illegal path character` out of `toFilePath`, because the decoded segment contains a separator and so names no file path at all. Likewise `GET /packages/foo.dart` throws an `ArgumentError` out of `PackageConfig.resolve`. Both are now answered like any other path that does not resolve inside a root. ### Tests `ReleaseAssetServer does not serve files outside of the project` is extended with the reference shapes that resolve outside a search root, and `WebAssetServer does not serve files outside of the project` is added as the debug-handler equivalent, including `dartSourceContents` and `/packages/` requests with an absolute path or nothing after the package name. Both fail against unmodified `lib/` and pass with the change. Positive tests cover a package path with a dot segment that stays inside the package root, and nested build output paths, including an asset name containing a colon in a later segment, which stays valid in a relative reference and must keep working. `dart analyze` and `dart format` are clean on the five touched files. Rebased on current `master`; locally both web suites (`devfs_web_test.dart`, `web_asset_server_test.dart`) pass in full, 84 tests. The 4 failures I mentioned in an earlier version of this description came from running them without `FLUTTER_ROOT` set. ### Issues There is no public issue tracking this; it came out of maintainer feedback rather than a filed report. Happy to open one if the process needs the link. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant in-code documentation (doc comments with `///`). - [x] If this PR introduces a new feature or capability, I created and linked a website documentation issue or PR in [flutter/website] (or verified none is needed). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [flutter/website]: https://github.com/flutter/website [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
flutter_tools' web asset servers resolve the incoming request path against each of their search roots withUri.resolve. That treats the request path as a URI reference rather than as a path, and a reference carrying a scheme, an authority or an absolute path replaces the base instead of resolving below it. The result of a lookup is therefore not guaranteed to be inside the root that was being searched, and nothing downstream checks that it is.This PR makes the containment explicit instead of relying on the shape of the request path.
Two helpers are added to
web_server_utilities.dart:isRelativeRequestPathrejects a request path that is not a relative reference: one with a scheme or a Windows drive letter in its first segment, an authority, an absolute path, a backslash, or a NUL.resolveRequestPathUnderresolves against a given root and returns the result only when it lands inside that root, compared withpath.isWithinso the comparison is per-component and case-insensitive on Windows.Every
resolve()call inReleaseAssetServer.handleandWebAssetServer.handleRequestnow goes through the helper, so each search root keeps its own boundary: the canvaskit fallback, the built asset fallback, the projectwebdirectory, and the roots consulted by_resolveDartFile..and..segments are deliberately still accepted and handled by the containment check, so a request path that normalizes back inside the root keeps working. A/packages/<package>/<path>request is resolved the same way below that package'spackageUriRoot, looked up by name, instead of through apackage:URI built from the request segments.ReleaseAssetServerchecks a root's allowed extensions against the request path before resolving it, so regular asset requests skip resolution for the source-map-only roots.This also keeps exceptions from escaping the handler. On
mastertoday,GET /..%2F..%2F..%2Ftmp/foothrowsUnsupportedError: Illegal path characterout oftoFilePath, because the decoded segment contains a separator and so names no file path at all. LikewiseGET /packages/foo.dartthrows anArgumentErrorout ofPackageConfig.resolve. Both are now answered like any other path that does not resolve inside a root.Tests
ReleaseAssetServer does not serve files outside of the projectis extended with the reference shapes that resolve outside a search root, andWebAssetServer does not serve files outside of the projectis added as the debug-handler equivalent, includingdartSourceContentsand/packages/requests with an absolute path or nothing after the package name. Both fail against unmodifiedlib/and pass with the change. Positive tests cover a package path with a dot segment that stays inside the package root, and nested build output paths, including an asset name containing a colon in a later segment, which stays valid in a relative reference and must keep working.dart analyzeanddart formatare clean on the five touched files. Rebased on currentmaster; locally both web suites (devfs_web_test.dart,web_asset_server_test.dart) pass in full, 84 tests. The 4 failures I mentioned in an earlier version of this description came from running them withoutFLUTTER_ROOTset.Issues
There is no public issue tracking this; it came out of maintainer feedback rather than a filed report. Happy to open one if the process needs the link.
Pre-launch Checklist
///).