|
@@ -0,0 +1,167 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Infrastructure\Enrichment\Downloaders;
|
|
|
|
|
+
|
|
|
|
|
+use GuzzleHttp\Exception\TransferException;
|
|
|
|
|
+use Psr\Http\Message\RequestInterface;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * SEC_REVIEW F50: defence-in-depth guard around the GeoIP downloader's
|
|
|
|
|
+ * HTTP client. Rejects requests whose URL points at a literal
|
|
|
|
|
+ * loopback / link-local / multicast / private host.
|
|
|
|
|
+ *
|
|
|
|
|
+ * The downloaders' base URLs are constants in the default deploy
|
|
|
|
|
+ * (`https://download.maxmind.com`, `https://download.db-ip.com`,
|
|
|
|
|
+ * `https://ipinfo.io`), so the only way a request to a private
|
|
|
|
|
+ * destination shows up here is via a 30x redirect or a DNS-poisoning
|
|
|
|
|
+ * MitM. Combined with `allow_redirects.protocols=['https']` and a
|
|
|
|
|
+ * tight redirect cap, this middleware blocks the post-redirect
|
|
|
|
|
+ * `http://169.254.169.254/...` / `https://localhost/...` /
|
|
|
|
|
+ * `https://10.0.0.1/...` patterns at the request layer before Guzzle
|
|
|
|
|
+ * opens a socket to the dangerous host.
|
|
|
|
|
+ *
|
|
|
|
|
+ * The guard inspects the URL's literal host string only — it does NOT
|
|
|
|
|
+ * resolve DNS to catch a public hostname that points at a private IP.
|
|
|
|
|
+ * Pinning DNS is out of scope for "defence in depth"; the primary
|
|
|
|
|
+ * controls are the constant base URL and the
|
|
|
|
|
+ * `allow_redirects.protocols=['https']` config.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Plug into a Guzzle handler stack with
|
|
|
|
|
+ * `$stack->push(PrivateHostGuardMiddleware::factory())`.
|
|
|
|
|
+ */
|
|
|
|
|
+final class PrivateHostGuardMiddleware
|
|
|
|
|
+{
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Hostnames + IP CIDRs we refuse to dial. Covers the AWS / GCP /
|
|
|
|
|
+ * Azure instance-metadata addresses, IPv6 link-local, and the
|
|
|
|
|
+ * RFC1918 ranges typical operators don't intend to expose.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @var list<string>
|
|
|
|
|
+ */
|
|
|
|
|
+ private const BLOCKED_HOSTS = [
|
|
|
|
|
+ 'localhost',
|
|
|
|
|
+ '0.0.0.0',
|
|
|
|
|
+ '169.254.169.254', // AWS / GCP / Azure metadata
|
|
|
|
|
+ 'metadata.google.internal',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Returns the middleware closure Guzzle expects. Signature is
|
|
|
|
|
+ * `function (callable $handler): callable`.
|
|
|
|
|
+ */
|
|
|
|
|
+ public static function factory(): callable
|
|
|
|
|
+ {
|
|
|
|
|
+ return static function (callable $handler): callable {
|
|
|
|
|
+ return static function (RequestInterface $request, array $options) use ($handler) {
|
|
|
|
|
+ self::assertHostAllowed($request);
|
|
|
|
|
+
|
|
|
|
|
+ return $handler($request, $options);
|
|
|
|
|
+ };
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Public so tests and the IPinfo downloader can call it directly
|
|
|
|
|
+ * (e.g. for redirect targets resolved out-of-band).
|
|
|
|
|
+ *
|
|
|
|
|
+ * @internal
|
|
|
|
|
+ */
|
|
|
|
|
+ public static function assertHostAllowed(RequestInterface $request): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $host = strtolower($request->getUri()->getHost());
|
|
|
|
|
+ if ($host === '') {
|
|
|
|
|
+ throw new TransferException('refusing to dial empty host');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Strip an IPv6 literal's brackets — `[::1]` → `::1`.
|
|
|
|
|
+ if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
|
|
|
|
|
+ $host = substr($host, 1, -1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (in_array($host, self::BLOCKED_HOSTS, true)) {
|
|
|
|
|
+ throw new TransferException(sprintf('refusing to dial blocked host: %s', $host));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // IPv4: literal dotted quad
|
|
|
|
|
+ if (preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $host) === 1) {
|
|
|
|
|
+ $ip = ip2long($host);
|
|
|
|
|
+ if ($ip === false) {
|
|
|
|
|
+ throw new TransferException(sprintf('refusing to dial malformed ipv4 host: %s', $host));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (self::ipv4InBlockedRanges($ip)) {
|
|
|
|
|
+ throw new TransferException(sprintf('refusing to dial private/loopback ipv4 host: %s', $host));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // IPv6: anything containing colons. Treat unique-local
|
|
|
|
|
+ // (fc00::/7), link-local (fe80::/10), and loopback (::1) as
|
|
|
|
|
+ // blocked; multicast (ff00::/8) too for good measure.
|
|
|
|
|
+ if (str_contains($host, ':')) {
|
|
|
|
|
+ $packed = @inet_pton($host);
|
|
|
|
|
+ if ($packed === false) {
|
|
|
|
|
+ throw new TransferException(sprintf('refusing to dial malformed ipv6 host: %s', $host));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (strlen($packed) === 16 && self::ipv6InBlockedRanges($packed)) {
|
|
|
|
|
+ throw new TransferException(sprintf('refusing to dial private/loopback ipv6 host: %s', $host));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static function ipv4InBlockedRanges(int $ip): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ // ip2long can return a negative int on 32-bit PHP for >2^31 hosts;
|
|
|
|
|
+ // operate on it as unsigned via bitwise AND with the netmask.
|
|
|
|
|
+ $ranges = [
|
|
|
|
|
+ ['10.0.0.0', 8],
|
|
|
|
|
+ ['127.0.0.0', 8],
|
|
|
|
|
+ ['169.254.0.0', 16], // link-local incl. 169.254.169.254 metadata
|
|
|
|
|
+ ['172.16.0.0', 12],
|
|
|
|
|
+ ['192.168.0.0', 16],
|
|
|
|
|
+ ['100.64.0.0', 10], // CGNAT
|
|
|
|
|
+ ['224.0.0.0', 4], // multicast
|
|
|
|
|
+ ['0.0.0.0', 8],
|
|
|
|
|
+ ];
|
|
|
|
|
+ foreach ($ranges as [$net, $prefix]) {
|
|
|
|
|
+ $netLong = ip2long((string) $net);
|
|
|
|
|
+ if ($netLong === false) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Each prefix in $ranges is in [4, 32]; no /0 case.
|
|
|
|
|
+ $mask = -1 << (32 - $prefix);
|
|
|
|
|
+ if (($ip & $mask) === ($netLong & $mask)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static function ipv6InBlockedRanges(string $packed16): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ $first = ord($packed16[0]);
|
|
|
|
|
+ // ::1 (loopback)
|
|
|
|
|
+ if ($packed16 === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01") {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ // ::0 (unspec)
|
|
|
|
|
+ if ($packed16 === str_repeat("\x00", 16)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ // fe80::/10 link-local
|
|
|
|
|
+ if ($first === 0xfe && (ord($packed16[1]) & 0xc0) === 0x80) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ // fc00::/7 unique-local
|
|
|
|
|
+ if (($first & 0xfe) === 0xfc) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ // ff00::/8 multicast
|
|
|
|
|
+ if ($first === 0xff) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|