|
@@ -26,6 +26,22 @@ final class MaxMindDownloader implements GeoIpDownloader
|
|
|
{
|
|
{
|
|
|
private const ENDPOINT = 'https://download.maxmind.com/app/geoip_download';
|
|
private const ENDPOINT = 'https://download.maxmind.com/app/geoip_download';
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F48: per-entry uncompressed-size cap. Real GeoLite2
|
|
|
|
|
+ * country/ASN MMDBs are ~6–7 MiB; 200 MiB is generous headroom
|
|
|
|
|
+ * against future growth while bounding the worst case at "no
|
|
|
|
|
+ * single entry can fill a small disk by itself".
|
|
|
|
|
+ */
|
|
|
|
|
+ private const MAX_ENTRY_BYTES = 200 * 1024 * 1024;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Per-archive cap on the sum of every entry's uncompressed size.
|
|
|
|
|
+ * The legitimate tarball is two MMDBs plus a couple of small text
|
|
|
|
|
+ * files (COPYRIGHT, LICENSE); 400 MiB sits well above that and
|
|
|
|
|
+ * well below "this could exhaust /data".
|
|
|
|
|
+ */
|
|
|
|
|
+ private const MAX_TOTAL_BYTES = 400 * 1024 * 1024;
|
|
|
|
|
+
|
|
|
public function __construct(
|
|
public function __construct(
|
|
|
private readonly ClientInterface $http,
|
|
private readonly ClientInterface $http,
|
|
|
private readonly string $licenseKey,
|
|
private readonly string $licenseKey,
|
|
@@ -88,7 +104,17 @@ final class MaxMindDownloader implements GeoIpDownloader
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
|
$phar = new PharData($tarPath);
|
|
$phar = new PharData($tarPath);
|
|
|
|
|
+ // SEC_REVIEW F48: walk the archive before extracting and
|
|
|
|
|
+ // enforce a per-entry + total uncompressed-size cap. A
|
|
|
|
|
+ // small .tar.gz that decompresses into multi-GB would
|
|
|
|
|
+ // otherwise exhaust disk before MmdbVerifier could see
|
|
|
|
|
+ // the file. PharData entries' `getSize()` reports the
|
|
|
|
|
+ // uncompressed size, so we can do the check without
|
|
|
|
|
+ // touching the filesystem.
|
|
|
|
|
+ self::assertSizeBudget($phar, $kind);
|
|
|
$phar->extractTo($extractDir, null, true);
|
|
$phar->extractTo($extractDir, null, true);
|
|
|
|
|
+ } catch (DownloaderException $e) {
|
|
|
|
|
+ throw $e;
|
|
|
} catch (Throwable $e) {
|
|
} catch (Throwable $e) {
|
|
|
throw new DownloaderException(
|
|
throw new DownloaderException(
|
|
|
sprintf('maxmind %s extract failed: %s', $kind, $e->getMessage()),
|
|
sprintf('maxmind %s extract failed: %s', $kind, $e->getMessage()),
|
|
@@ -133,4 +159,50 @@ final class MaxMindDownloader implements GeoIpDownloader
|
|
|
|
|
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F48: zip-bomb guard for the MaxMind tarball. Walks
|
|
|
|
|
+ * every entry recursively, summing uncompressed sizes, and
|
|
|
|
|
+ * throws if any single entry exceeds `$maxEntryBytes` or the
|
|
|
|
|
+ * total exceeds `$maxTotalBytes`. The check runs *before*
|
|
|
|
|
+ * `extractTo`, so an attacker who serves a bomb tarball never
|
|
|
|
|
+ * gets a single decompressed byte on disk.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Public-but-`@internal` so tests can drive it with small caps
|
|
|
|
|
+ * without having to build a real >200 MiB tarball.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @internal
|
|
|
|
|
+ */
|
|
|
|
|
+ public static function assertSizeBudget(
|
|
|
|
|
+ PharData $phar,
|
|
|
|
|
+ string $kind,
|
|
|
|
|
+ int $maxEntryBytes = self::MAX_ENTRY_BYTES,
|
|
|
|
|
+ int $maxTotalBytes = self::MAX_TOTAL_BYTES,
|
|
|
|
|
+ ): void {
|
|
|
|
|
+ $total = 0;
|
|
|
|
|
+ $it = new RecursiveIteratorIterator($phar);
|
|
|
|
|
+ foreach ($it as $entry) {
|
|
|
|
|
+ if (!$entry->isFile()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $size = $entry->getSize();
|
|
|
|
|
+ if ($size > $maxEntryBytes) {
|
|
|
|
|
+ throw new DownloaderException(sprintf(
|
|
|
|
|
+ 'maxmind %s entry %s uncompressed size %d > cap %d',
|
|
|
|
|
+ $kind,
|
|
|
|
|
+ $entry->getFilename(),
|
|
|
|
|
+ $size,
|
|
|
|
|
+ $maxEntryBytes,
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ $total += $size;
|
|
|
|
|
+ if ($total > $maxTotalBytes) {
|
|
|
|
|
+ throw new DownloaderException(sprintf(
|
|
|
|
|
+ 'maxmind %s archive uncompressed size > cap %d',
|
|
|
|
|
+ $kind,
|
|
|
|
|
+ $maxTotalBytes,
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|