Files
ac-ms-url-shortener/src/UrlNormalizer.php
Anton Afanasyeu 106e9ad52a allow f0xx.org domains in URL normalizer SSRF guard
The project's own domains (f0xx.org and *.f0xx.org) resolve to
private/internal IPs inside the cluster network, causing the SSRF
guard to block legitimate shortening requests for internal services
such as Alertmanager notifications.  Add an explicit early-return
for f0xx.org and sub-domains before the private-IP check.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:09:02 +02:00

66 lines
2.5 KiB
PHP

<?php
declare(strict_types=1);
final class UrlNormalizer {
private const MAX_LEN = 2048;
public static function normalize(string $raw): string {
$url = trim($raw);
if ($url === '') {
throw new InvalidArgumentException('empty url');
}
if (strlen($url) > self::MAX_LEN) {
throw new InvalidArgumentException('url too long');
}
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
throw new InvalidArgumentException('malformed url');
}
$scheme = strtolower((string) $parts['scheme']);
if ($scheme !== 'http' && $scheme !== 'https') {
throw new InvalidArgumentException('unsupported scheme');
}
$host = strtolower((string) $parts['host']);
self::assertHostAllowed($host);
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
$path = $parts['path'] ?? '';
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
return $scheme . '://' . $host . $port . $path . $query . $fragment;
}
public static function originHash(string $normalized): string {
return hash('sha256', $normalized);
}
private static function assertHostAllowed(string $host): void {
if ($host === 'localhost' || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
throw new InvalidArgumentException('blocked host');
}
if ($host === 'metadata.google.internal' || $host === 'metadata') {
throw new InvalidArgumentException('blocked host');
}
// Project-owned apex domain and all sub-domains are always allowed regardless of
// where their DNS resolves to inside the cluster network.
if ($host === 'f0xx.org' || str_ends_with($host, '.f0xx.org')) {
return;
}
if (filter_var($host, FILTER_VALIDATE_IP)) {
self::assertIpAllowed($host);
return;
}
$resolved = @gethostbyname($host);
if ($resolved !== $host && filter_var($resolved, FILTER_VALIDATE_IP)) {
self::assertIpAllowed($resolved);
}
}
private static function assertIpAllowed(string $ip): void {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
throw new InvalidArgumentException('blocked host');
}
}
}