fix: harden downloader against path traversal, URL escape, oversized bodies
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -70,12 +70,25 @@ class ContentDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _downloadPack(ManifestSeason season) async {
|
Future<bool> _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 {
|
try {
|
||||||
final res = await _client
|
final res = await _client
|
||||||
.get(Uri.parse('$baseUrl/${season.packUrl}'))
|
.get(Uri.parse('$baseUrl/${season.packUrl}'))
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
if (res.statusCode != 200) return false;
|
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
|
// Verify in memory before any file is touched, so a bad pack can
|
||||||
// never leave artifacts on disk.
|
// never leave artifacts on disk.
|
||||||
final digest = sha256.convert(res.bodyBytes).toString();
|
final digest = sha256.convert(res.bodyBytes).toString();
|
||||||
|
|||||||
@@ -129,4 +129,51 @@ void main() {
|
|||||||
).sync();
|
).sync();
|
||||||
expect(updated, isTrue);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user