Files
toolvana/app/Libraries/UrlGuard.php
T
deepseek beaf0e1f37 Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions,
  JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter)
- DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD),
  Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG)
- Security: SSRF guard, MIME validation, rate limits, API-key auth,
  bcrypt admin login, security headers
- Admin panel: dashboard, tools/categories/guides CRUD, SEO audit,
  analytics, job inspector with retry, system health, feature flags
- Docker deployment (nginx + web/api FPM pools + scalable workers),
  PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
2026-08-23 07:10:30 +00:00

94 lines
3.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* SSRF protection for user-supplied URLs. Every external fetch goes
* through guard() before a single byte is requested:
*
* - scheme allowlist (http/https)
* - DNS resolution checked against private / reserved ranges
* - port restricted to 80/443
* - optional host allowlist for media CDNs
* - redirects are never auto-followed by fetchers (re-validate per hop)
*/
final class UrlGuard
{
public function check(string $url): array
{
$parts = parse_url(trim($url));
if ($parts === false || ! isset($parts['host'], $parts['scheme'])) {
throw new \InvalidArgumentException('Invalid URL.');
}
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
throw new \InvalidArgumentException('Only http and https URLs are allowed.');
}
$port = $parts['port'] ?? (strtolower($parts['scheme']) === 'https' ? 443 : 80);
if (! in_array($port, [80, 443], true)) {
throw new \InvalidArgumentException('Blocked port.');
}
$host = strtolower($parts['host']);
// Never let raw IPs through unless public.
if (filter_var($host, FILTER_VALIDATE_IP)) {
if (! $this->isPublicIp($host)) {
throw new \InvalidArgumentException('Blocked host.');
}
return ['host' => $host, 'ip' => $host, 'port' => $port, 'path' => $parts['path'] ?? '/', 'query' => $parts['query'] ?? ''];
}
// Resolve all addresses; every one must be public.
$records = @dns_get_record($host, DNS_A + DNS_AAAA);
$ips = [];
foreach ($records ?: [] as $record) {
$ip = $record['type'] === 'A' ? ($record['ip'] ?? null) : ($record['ipv6'] ?? null);
if ($ip !== null) {
$ips[] = $ip;
}
}
if ($ips === []) {
// fall back to gethostbyname for simple A records
$resolved = gethostbyname($host);
if ($resolved === $host) {
throw new \InvalidArgumentException('Could not resolve host.');
}
$ips = [$resolved];
}
foreach ($ips as $ip) {
if (! $this->isPublicIp($ip)) {
throw new \InvalidArgumentException('Blocked host.');
}
}
return ['host' => $host, 'ips' => $ips, 'port' => $port, 'path' => $parts['path'] ?? '/', 'query' => $parts['query'] ?? ''];
}
/** Host allowlist used for direct CDN fetches (thumbnails etc). */
public function isAllowedMediaHost(string $host): bool
{
return in_array(strtolower($host), config('Site')->urlFetchAllowlist, true);
}
public function isPublicIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && str_starts_with($ip, '::ffff:')) {
$ip = substr($ip, 7);
}
$blockedFlags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE;
if (str_contains($ip, ':')) {
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | $blockedFlags) !== false;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | $blockedFlags) !== false;
}
}