Information disclosure / Local file disclosure via web asset server
Description
The ReleaseAssetServer in flutter_tools previously scanned three roots (build output, Flutter SDK root, and the project root) to resolve each request. Because the project root and the Flutter SDK root were included in the search without restricting file extensions, arbitrary files under those roots (for example, .env, keystore/signing configs, or SDK internals) could be exposed via the web asset server. The patch tightens these roots to only serve source-map related files (.dart and .map) from the project and SDK roots, while the build/web output remains unrestricted. This fixes an information-disclosure vulnerability where sensitive project/SDK files could be exposed to remote clients when serving release/profile/wasm web builds.
Proof of Concept
PoC in a controlled lab environment:
1) Prerequisites: A Flutter project with sensitive files at the project root, and the web asset server enabled (e.g., via flutter run -d web-server).
- Create a sensitive file at the project root: .env with content: API_KEY=super-secret-key
- Optionally create a sensitive SDK-like file under the Flutter SDK root, e.g., flutter/bin/internal/engine.version
2) Start the web asset server (release asset server) as used by flutter web builds:
- flutter run -d web-server
- This serves build/web contents and resolves source-map references from project/SDK roots.
3) Attack vector (pre-fix behavior):
- Make an HTTP GET request for a sensitive file that exists under the project/SDK roots but is not a Dart/Map source file, e.g.:
curl -s http://localhost:8080/.env | head -n 5
- Expected (pre-fix): The server would return the contents of .env, exposing secrets such as API keys.
- Same for an SDK file: curl -s http://localhost:8080/flutter/bin/internal/engine.version | head -n 5
4) Expected exploit results (pre-fix):
- The response body contains real secrets from the exposed files, confirming information disclosure.
5) Patch effect (post-fix):
- After applying the fix, requests to non-source files under the project or Flutter SDK roots are blocked from being served. The server will not return .env or engine.version contents from those roots; instead, it will fall back to serving index.html (as per the test case) or return a 404/HTML fallback depending on implementation.
6) Verification commands (post-fix):
- curl -s http://localhost:8080/.env
- Expected: HTML index fallback (or 404), not the file contents.
- curl -s http://localhost:8080/flutter/bin/internal/engine.version
- Expected: HTML index fallback (or 404), not the file contents.
Notes:
- The build/web directory remains unrestricted to serve generated assets. The protection only applies to project/SDK roots for non-source-map files, preserving legitimate source-map resolution behavior while preventing exposure of sensitive files.
Commit Details
Author: Adil Burak Şen
Date: 2026-06-24 21:39 UTC
Message:
[flutter_tools] Restrict release web asset server to the build output and source files (#187437)
## Summary
The release web asset server (`ReleaseAssetServer`, used for
release/profile/wasm
web builds served via `flutter run -d web-server`/`chrome`) searches
three roots
when resolving each request:
```dart
List<Uri> _searchPaths() => <Uri>[
_fileSystem.directory(_webBuildDirectory).uri,
_fileSystem.directory(_flutterRoot).uri,
_fileSystem.currentDirectory.uri,
];
```
and returns every matched file with `Access-Control-Allow-Origin: *`.
The project root (`currentDirectory`) and the Flutter SDK root
(`_flutterRoot`)
are only needed so that the original Dart sources and `.map` files
referenced by
a build's **source maps** can be resolved. Because the whole roots were
searched
for any request, unrelated files under them — for example `.env`,
`android/key.properties`, or other project/SDK files — were served as
well, in
addition to the intended web build output.
This is a follow-up to #180699 ("[web] Don't serve files outside of
project"),
which removed the home directory and the SDK parent directory from the
search
paths but intentionally kept the project root and SDK root for
source-map
resolution. This change tightens those two remaining roots.
## Change
Restrict the project root and Flutter SDK root to the file extensions
that
source-map resolution actually needs (`.dart` and `.map`). The web build
output
directory stays unrestricted because it only contains generated,
publishable
assets.
Result:
- Web build output (`build/web`) — served as before (any file type).
- Source files referenced by source maps (`lib/main.dart`, SDK `.dart`,
`.map`)
— still served from the project and SDK roots.
- Unrelated files under the project/SDK roots (`.env`, signing config,
etc.)
— no longer served; they fall through to the `index.html` fallback.
## Test
Adds a test to `web_asset_server_test.dart` asserting that source files
are
still served from the project and SDK roots, while non-source files
under them
are not. `dart analyze` is clean for both changed files.
Triage Assessment
Vulnerability Type: Information disclosure
Confidence: HIGH
Reasoning:
The change tightens which files can be served by the ReleaseAssetServer, preventing exposure of sensitive project and SDK files (e.g., .env, signing configs) while still serving legitimate source-map related files. This directly mitigates a potential information disclosure vulnerability.
Verification Assessment
Vulnerability Type: Information disclosure / Local file disclosure via web asset server
Confidence: HIGH
Affected Versions: <= 1.16.3
Code Diff
diff --git a/packages/flutter_tools/lib/src/isolated/release_asset_server.dart b/packages/flutter_tools/lib/src/isolated/release_asset_server.dart
index 94e97c03802a1..22d2e3387fe0d 100644
--- a/packages/flutter_tools/lib/src/isolated/release_asset_server.dart
+++ b/packages/flutter_tools/lib/src/isolated/release_asset_server.dart
@@ -43,11 +43,22 @@ class ReleaseAssetServer {
@visibleForTesting
final String basePath;
- // Locations where source files, assets, or source maps may be located.
- List<Uri> _searchPaths() => <Uri>[
- _fileSystem.directory(_webBuildDirectory).uri,
- _fileSystem.directory(_flutterRoot).uri,
- _fileSystem.currentDirectory.uri,
+ // File extensions that may legitimately be requested from the project and
+ // Flutter SDK roots. These roots only need to satisfy source-map source
+ // resolution (the original Dart sources and `.map` files referenced by a
+ // build's source maps), so requests for other files (for example `.env`,
+ // keystore/signing config, or arbitrary project files) are not served from
+ // them. The web build output directory is unrestricted because it only
+ // contains generated, publishable assets.
+ static const Set<String> _sourceMapExtensions = <String>{'.dart', '.map'};
+
+ // Locations where source files, assets, or source maps may be located, paired
+ // with the set of file extensions allowed to be served from each location. An
+ // empty set means no extension restriction is applied.
+ List<(Uri, Set<String>)> _searchPaths() => <(Uri, Set<String>)>[
+ (_fileSystem.directory(_webBuildDirectory).uri, const <String>{}),
+ (_fileSystem.directory(_flutterRoot).uri, _sourceMapExtensions),
+ (_fileSystem.currentDirectory.uri, _sourceMapExtensions),
];
Future<shelf.Response> handle(shelf.Request request) async {
@@ -66,9 +77,16 @@ class ReleaseAssetServer {
if (request.url.toString() == 'main.dart') {
fileUri = entrypoint;
} else {
- for (final Uri uri in _searchPaths()) {
+ for (final (Uri uri, Set<String> allowedExtensions) in _searchPaths()) {
final Uri potential = uri.resolve(requestPath);
- if (_fileSystem.isFileSync(potential.toFilePath(windows: _platform.isWindows))) {
+ final String potentialPath = potential.toFilePath(windows: _platform.isWindows);
+ if (allowedExtensions.isNotEmpty &&
+ !allowedExtensions.contains(_fileSystem.path.extension(potentialPath))) {
+ // This root only serves source-map related files; skip anything else
+ // so unrelated project or SDK files are not exposed.
+ continue;
+ }
+ if (_fileSystem.isFileSync(potentialPath)) {
fileUri = potential;
break;
}
diff --git a/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart b/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart
index 0f1b37c63072a..8c3d968e75292 100644
--- a/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart
+++ b/packages/flutter_tools/test/general.shard/web/web_asset_server_test.dart
@@ -223,6 +223,75 @@ void main() {
expect(response.statusCode, HttpStatus.ok);
});
+ testWithoutContext(
+ 'release asset server does not serve non-source files from the project or flutter root',
+ () async {
+ final assetServer = ReleaseAssetServer(
+ Uri.base,
+ fileSystem: fileSystem,
+ platform: platform,
+ flutterRoot: '/flutter',
+ webBuildDirectory: 'build/web',
+ needsCoopCoep: false,
+ );
+ // The build output (index.html) is the fallback response for anything
+ // that is not served directly.
+ fileSystem.file('build/web/index.html')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('<html></html>');
+
+ // Files that may legitimately be requested for source-map resolution.
+ fileSystem.file('lib/main.dart')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('void main() { }');
+ fileSystem.file('flutter/packages/flutter/lib/widget.dart')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('// sdk source');
+
+ // Files in the project root and flutter root that should not be served.
+ fileSystem.file('.env')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('API_KEY=super-secret');
+ fileSystem.file('android/key.properties')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('storePassword=hunter2');
+ fileSystem.file('flutter/bin/internal/engine.version')
+ ..createSync(recursive: true)
+ ..writeAsStringSync('deadbeef');
+
+ // Source files referenced by source maps are still served from the
+ // project and flutter roots.
+ for (final path in <String>['lib/main.dart', 'flutter/packages/flutter/lib/widget.dart']) {
+ final Response response = await assetServer.handle(
+ Request('GET', Uri.parse('http://localhost:8080/$path')),
+ );
+ expect(response.statusCode, HttpStatus.ok, reason: '"$path" should be served');
+ expect(
+ await response.readAsString(),
+ isNot('<html></html>'),
+ reason: '"$path" should be served, not the index.html fallback',
+ );
+ }
+
+ // Unrelated files in the project/flutter roots fall through to the
+ // index.html fallback instead of being served.
+ for (final path in <String>[
+ '.env',
+ 'android/key.properties',
+ 'flutter/bin/internal/engine.version',
+ ]) {
+ final Response response = await assetServer.handle(
+ Request('GET', Uri.parse('http://localhost:8080/$path')),
+ );
+ expect(
+ await response.readAsString(),
+ '<html></html>',
+ reason: '"$path" should not be served and should return the index.html fallback',
+ );
+ }
+ },
+ );
+
testWithoutContext(
'release asset server serves html content with COOP/COEP headers when specified',
() async {