|
|
@@ -0,0 +1,249 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Http;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Trusted-proxy helper for `X-Forwarded-For` and `X-Forwarded-Proto`
|
|
|
+ * (R01-N05 / R01-N07).
|
|
|
+ *
|
|
|
+ * Operators populate `TRUSTED_PROXIES` in `.env` with a comma-separated list
|
|
|
+ * of CIDRs (e.g. `10.0.0.0/8, 192.168.0.0/16, 2001:db8::/32`). Bare IPs
|
|
|
+ * without `/n` are treated as `/32` (IPv4) or `/128` (IPv6). With the env
|
|
|
+ * var blank — the default — the helper trusts no proxy and returns
|
|
|
+ * `REMOTE_ADDR` verbatim.
|
|
|
+ *
|
|
|
+ * Two consumers:
|
|
|
+ *
|
|
|
+ * - `clientIp(array $server)` walks rightmost-to-leftmost through
|
|
|
+ * `X-Forwarded-For` (with `REMOTE_ADDR` appended logically), returning
|
|
|
+ * the first hop that is not a trusted proxy. This is the IP that goes
|
|
|
+ * into the audit log and the local-admin throttle bucket (R01-N07).
|
|
|
+ *
|
|
|
+ * - `isHttps(array $server)` recognises a request as HTTPS when either
|
|
|
+ * the server-side scheme is already TLS (`HTTPS=on`, port 443, etc.)
|
|
|
+ * OR `X-Forwarded-Proto: https` arrived from a trusted proxy
|
|
|
+ * (R01-N05).
|
|
|
+ *
|
|
|
+ * Untrusted forwarded headers are ignored; an attacker can't lie their way
|
|
|
+ * into a different audit IP or HTTPS posture by hand-crafting request
|
|
|
+ * headers, because their transport-layer `REMOTE_ADDR` will not match a
|
|
|
+ * configured CIDR.
|
|
|
+ */
|
|
|
+final class TrustedProxies
|
|
|
+{
|
|
|
+ /** @var array<int, array{0:string,1:int}> list of [networkBin, prefixBits] */
|
|
|
+ private array $cidrs;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param string[] $cidrs CIDR strings such as "10.0.0.0/8" or "127.0.0.1".
|
|
|
+ * Invalid entries are silently skipped — operators
|
|
|
+ * get the secure default (no trust) on a typo.
|
|
|
+ */
|
|
|
+ public function __construct(array $cidrs)
|
|
|
+ {
|
|
|
+ $parsed = [];
|
|
|
+ foreach ($cidrs as $raw) {
|
|
|
+ $entry = self::parseCidr((string) $raw);
|
|
|
+ if ($entry !== null) {
|
|
|
+ $parsed[] = $entry;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $this->cidrs = $parsed;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Build from `TRUSTED_PROXIES`. Empty / unset → trusts nothing. */
|
|
|
+ public static function fromEnv(): self
|
|
|
+ {
|
|
|
+ $raw = getenv('TRUSTED_PROXIES');
|
|
|
+ if (!is_string($raw) || trim($raw) === '') {
|
|
|
+ return new self([]);
|
|
|
+ }
|
|
|
+ $parts = array_filter(
|
|
|
+ array_map('trim', explode(',', $raw)),
|
|
|
+ static fn(string $p): bool => $p !== '',
|
|
|
+ );
|
|
|
+ return new self(array_values($parts));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** True iff `$ip` falls inside any configured CIDR. */
|
|
|
+ public function isTrusted(string $ip): bool
|
|
|
+ {
|
|
|
+ $bin = @inet_pton($ip);
|
|
|
+ if ($bin === false) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ foreach ($this->cidrs as [$rangeBin, $bits]) {
|
|
|
+ if (strlen($bin) !== strlen($rangeBin)) {
|
|
|
+ continue; // family mismatch (v4 vs v6)
|
|
|
+ }
|
|
|
+ if (self::matchPrefix($bin, $rangeBin, $bits)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Resolve the originating client IP for a request.
|
|
|
+ *
|
|
|
+ * Algorithm (RFC 7239 / X-Forwarded-For convention):
|
|
|
+ * 1. If `REMOTE_ADDR` itself is not in a trusted CIDR → return it as-is.
|
|
|
+ * The forwarded header cannot be trusted from an arbitrary client.
|
|
|
+ * 2. Otherwise build the chain `[XFF entries…, REMOTE_ADDR]` and walk
|
|
|
+ * from right (closest to us) to left (closest to the client), skipping
|
|
|
+ * hops that are still trusted. The first non-trusted hop is the
|
|
|
+ * client.
|
|
|
+ * 3. If every hop is trusted (e.g. an internal-only deployment) we fall
|
|
|
+ * back to the leftmost XFF entry; that's the most informative value
|
|
|
+ * we have.
|
|
|
+ *
|
|
|
+ * @param array<string,mixed> $server $_SERVER-shaped map.
|
|
|
+ */
|
|
|
+ public function clientIp(array $server): string
|
|
|
+ {
|
|
|
+ $remote = self::scalar($server, 'REMOTE_ADDR');
|
|
|
+ if ($remote === '') {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ if (!$this->isTrusted($remote)) {
|
|
|
+ return $remote;
|
|
|
+ }
|
|
|
+
|
|
|
+ $xffRaw = self::scalar($server, 'HTTP_X_FORWARDED_FOR');
|
|
|
+ if ($xffRaw === '') {
|
|
|
+ return $remote;
|
|
|
+ }
|
|
|
+
|
|
|
+ $hops = array_values(array_filter(
|
|
|
+ array_map(
|
|
|
+ static fn(string $hop): string => self::stripPort(trim($hop)),
|
|
|
+ explode(',', $xffRaw),
|
|
|
+ ),
|
|
|
+ static fn(string $hop): bool => $hop !== '',
|
|
|
+ ));
|
|
|
+ if ($hops === []) {
|
|
|
+ return $remote;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Walk rightmost-to-leftmost; skip trusted hops, return first
|
|
|
+ // untrusted one.
|
|
|
+ for ($i = count($hops) - 1; $i >= 0; $i--) {
|
|
|
+ $hop = $hops[$i];
|
|
|
+ if (!$this->isTrusted($hop)) {
|
|
|
+ return $hop;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Every hop is trusted — return leftmost as the "deepest" guess.
|
|
|
+ return $hops[0];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * True iff the request is effectively over HTTPS.
|
|
|
+ *
|
|
|
+ * Server-side TLS (`HTTPS=on`, port 443, `REQUEST_SCHEME=https`) is
|
|
|
+ * always trusted. `X-Forwarded-Proto: https` is only honoured when the
|
|
|
+ * immediate `REMOTE_ADDR` is in a trusted CIDR — otherwise an off-net
|
|
|
+ * attacker could lie their way into Secure-cookie + HSTS behaviour
|
|
|
+ * during a downgrade attack.
|
|
|
+ *
|
|
|
+ * @param array<string,mixed> $server $_SERVER-shaped map.
|
|
|
+ */
|
|
|
+ public function isHttps(array $server): bool
|
|
|
+ {
|
|
|
+ $https = strtolower(self::scalar($server, 'HTTPS'));
|
|
|
+ if ($https !== '' && $https !== 'off') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ $scheme = strtolower(self::scalar($server, 'REQUEST_SCHEME'));
|
|
|
+ if ($scheme === 'https') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ $port = self::scalar($server, 'SERVER_PORT');
|
|
|
+ if ($port === '443') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ $remote = self::scalar($server, 'REMOTE_ADDR');
|
|
|
+ if ($remote === '' || !$this->isTrusted($remote)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ $proto = strtolower(trim(self::scalar($server, 'HTTP_X_FORWARDED_PROTO')));
|
|
|
+ if ($proto === '') {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // Some proxies (HAProxy, AWS ALB) emit a comma-separated list when
|
|
|
+ // there is more than one hop. Take the leftmost entry — the one set
|
|
|
+ // by the outermost proxy that actually saw the client.
|
|
|
+ $first = strtolower(trim(strtok($proto, ',') ?: ''));
|
|
|
+ return $first === 'https';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @return array{0:string,1:int}|null parsed CIDR or null when invalid.
|
|
|
+ */
|
|
|
+ private static function parseCidr(string $raw): ?array
|
|
|
+ {
|
|
|
+ if ($raw === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $slash = strpos($raw, '/');
|
|
|
+ $addr = $slash === false ? $raw : substr($raw, 0, $slash);
|
|
|
+ $bin = @inet_pton($addr);
|
|
|
+ if ($bin === false) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $maxBits = strlen($bin) * 8; // 32 for v4, 128 for v6
|
|
|
+ if ($slash === false) {
|
|
|
+ return [$bin, $maxBits];
|
|
|
+ }
|
|
|
+ $bitsRaw = substr($raw, $slash + 1);
|
|
|
+ if ($bitsRaw === '' || !ctype_digit($bitsRaw)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $bits = (int) $bitsRaw;
|
|
|
+ if ($bits < 0 || $bits > $maxBits) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return [$bin, $bits];
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Compare the leftmost `$bits` bits of two `inet_pton` byte strings. */
|
|
|
+ private static function matchPrefix(string $a, string $b, int $bits): bool
|
|
|
+ {
|
|
|
+ $fullBytes = intdiv($bits, 8);
|
|
|
+ if ($fullBytes > 0 && substr($a, 0, $fullBytes) !== substr($b, 0, $fullBytes)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ $remaining = $bits % 8;
|
|
|
+ if ($remaining === 0) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ $mask = (~((1 << (8 - $remaining)) - 1)) & 0xFF;
|
|
|
+ return (ord($a[$fullBytes]) & $mask) === (ord($b[$fullBytes]) & $mask);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function stripPort(string $hop): string
|
|
|
+ {
|
|
|
+ if ($hop === '' || $hop[0] === '[') {
|
|
|
+ // bracketed IPv6: "[::1]:443" → "::1"
|
|
|
+ $end = strpos($hop, ']');
|
|
|
+ return $end === false ? $hop : substr($hop, 1, $end - 1);
|
|
|
+ }
|
|
|
+ // IPv4 with port: "1.2.3.4:5678" → "1.2.3.4". Bare IPv6 has multiple
|
|
|
+ // colons and no brackets; leave it alone.
|
|
|
+ if (substr_count($hop, ':') === 1) {
|
|
|
+ return substr($hop, 0, (int) strpos($hop, ':'));
|
|
|
+ }
|
|
|
+ return $hop;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @param array<string,mixed> $server */
|
|
|
+ private static function scalar(array $server, string $key): string
|
|
|
+ {
|
|
|
+ $v = $server[$key] ?? '';
|
|
|
+ return is_scalar($v) ? (string) $v : '';
|
|
|
+ }
|
|
|
+}
|