From a820e972378c71d24f57f082f687d5ab8f9d76fc Mon Sep 17 00:00:00 2001 From: airkjw Date: Fri, 12 Jun 2026 13:11:18 +0900 Subject: [PATCH] fix: harden downloader against path traversal, URL escape, oversized bodies Co-Authored-By: Claude Fable 5 --- lib/data/remote/content_downloader.dart | 13 +++++ test/data/remote/content_downloader_test.dart | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/lib/data/remote/content_downloader.dart b/lib/data/remote/content_downloader.dart index 1bac535..e271c8c 100644 --- a/lib/data/remote/content_downloader.dart +++ b/lib/data/remote/content_downloader.dart @@ -70,12 +70,25 @@ class ContentDownloader { } Future _downloadPack(ManifestSeason season) async { + // seasonId becomes a filesystem path segment; only accept safe slugs so a + // hostile manifest can never write outside the cache dir. + if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(season.seasonId)) return false; + + if (season.packUrl.contains('..') || + season.packUrl.startsWith('/') || + season.packUrl.contains('://')) { + return false; + } + try { final res = await _client .get(Uri.parse('$baseUrl/${season.packUrl}')) .timeout(const Duration(seconds: 30)); if (res.statusCode != 200) return false; + // Packs are tens of KB; anything huge is a server error or an attack. + if (res.bodyBytes.length > 5 * 1024 * 1024) return false; + // Verify in memory before any file is touched, so a bad pack can // never leave artifacts on disk. final digest = sha256.convert(res.bodyBytes).toString(); diff --git a/test/data/remote/content_downloader_test.dart b/test/data/remote/content_downloader_test.dart index 4b8f3e1..552606d 100644 --- a/test/data/remote/content_downloader_test.dart +++ b/test/data/remote/content_downloader_test.dart @@ -129,4 +129,51 @@ void main() { ).sync(); expect(updated, isTrue); }); + + test('malicious seasonId never escapes the cache dir', () async { + final outside = Directory('${tmp.path}/../bs_escape_probe'); + addTearDown(() async { + if (await outside.exists()) await outside.delete(recursive: true); + }); + final client = MockClient((request) async { + if (request.url.path.endsWith('manifest.json')) { + return http.Response( + jsonEncode({ + 'schemaVersion': 1, + 'minAppBuild': 1, + 'current': 'x', + 'seasons': [ + { + 'seasonId': '../bs_escape_probe', + 'version': 1, + 'packUrl': 'seasons/x/pack.json', + 'sha256': shaOf(packBody), + }, + ], + }), + 200, + ); + } + return http.Response(packBody, 200); + }); + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: client, + ); + expect(await downloader.sync(), isFalse); + expect(await outside.exists(), isFalse); + }); + + test('corrupt cache index is treated as empty and re-downloads', () async { + await File('${tmp.path}/cache_index.json').create(recursive: true); + await File('${tmp.path}/cache_index.json').writeAsString('{broken'); + final downloader = ContentDownloader( + baseUrl: 'https://example.com/content', + cacheDir: tmp, + client: okClient(), + ); + expect(await downloader.sync(), isTrue); + expect(downloader.cachedVersion('season_002'), 1); + }); }