- 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
94 lines
3.2 KiB
PHP
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;
|
|
}
|
|
}
|