Files
landing-api/app/Services/Media/RemoteImage.php
Josep Piguillem 7e672a5e62 Serve uploaded SVGs under their CSP, and identify the image fetcher
The Content-Security-Policy meant for stored SVGs never applied: in
Caddy a `*` in the middle of a path does not cross a `/`, so
`path /storage/*.svg` only matched files directly under /storage, and
every upload lives in a directory below it. Two matchers, which Caddy
ANDs, cover any depth.

RemoteImage fetched with Guzzle's anonymous default User-Agent, which
Wikimedia answers with 403. It now sends `<app>/1.0 (+<app url>; media
fetcher)`, the identification such hosts ask of automated clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:43:59 +02:00

150 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Media;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* Downloads an image from a URL someone handed the API, without letting that
* URL reach anything it should not.
*
* The request is made by this server, from inside the stack's network, so an
* unchecked URL is a way to make it fetch `http://postgres:5432`, the cloud
* metadata service, or the application itself. Hence:
*
* - HTTPS only.
* - Every address the host resolves to must be a public one — a single private
* address among several is enough to refuse.
* - The connection is pinned to the address that was checked (CURLOPT_RESOLVE),
* so a DNS answer that changes between the check and the request — DNS
* rebinding — cannot swap in a private one.
* - Redirects are not followed: the target of one would need all of the above
* again, and "use the final address" is an easy thing to ask for.
* - The body is read to a ceiling and no further, whatever the headers claim.
*
* It says who is asking. Hosts such as Wikimedia refuse Guzzle's anonymous
* default outright, and a User-Agent with a contact address is what their
* policies ask of anything that fetches automatically.
*/
class RemoteImage
{
public function __construct(protected HostResolver $resolver) {}
/**
* @return string The file's bytes.
*
* @throws ValidationException With the reason, on the `url` field.
*/
public function fetch(string $url): string
{
$parts = parse_url($url);
$host = $parts['host'] ?? null;
if (($parts['scheme'] ?? null) !== 'https' || blank($host)) {
$this->fail('https_only');
}
$host = trim((string) $host, '[]');
$port = (int) ($parts['port'] ?? 443);
$address = $this->publicAddressFor($host);
$maxBytes = (int) config('media.upload.max_kb') * 1024;
try {
$response = Http::withOptions([
'allow_redirects' => false,
'stream' => true,
'connect_timeout' => 5,
'timeout' => (int) config('media.upload.remote_timeout', 15),
'curl' => [CURLOPT_RESOLVE => [sprintf(
'%s:%d:%s',
$host,
$port,
str_contains($address, ':') ? "[{$address}]" : $address,
)]],
])
->withHeaders(['Accept' => 'image/*'])
->withUserAgent($this->userAgent())
->get($url);
} catch (ConnectionException) {
$this->fail('unreachable');
}
if ($response->redirect()) {
$this->fail('redirect');
}
if ($response->status() !== 200) {
$this->fail('status', ['status' => $response->status()]);
}
if ((int) $response->header('Content-Length') > $maxBytes) {
$this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]);
}
$body = $response->toPsrResponse()->getBody();
$contents = '';
while (! $body->eof()) {
$contents .= $body->read(65536);
if (strlen($contents) > $maxBytes) {
$this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]);
}
}
return $contents;
}
/**
* `pireon-admin/1.0 (+https://admin.example.org; media fetcher)`: a product
* token without spaces, and the installation's own address to reach it by.
*/
protected function userAgent(): string
{
return sprintf(
'%s/1.0 (+%s; media fetcher)',
Str::slug((string) config('app.name')) ?: 'landing',
rtrim((string) config('app.url'), '/'),
);
}
/**
* The address to connect to, once every address the host has is known to
* be public.
*/
protected function publicAddressFor(string $host): string
{
$addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolver->resolve($host);
if ($addresses === []) {
$this->fail('unresolvable');
}
foreach ($addresses as $address) {
// GLOBAL_RANGE is the strict one: besides the private and reserved
// blocks it refuses shared (100.64/10), link-local and the
// IPv4-mapped IPv6 spelling of any of them.
if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) {
$this->fail('private');
}
}
return $addresses[0];
}
/**
* @param array<string, mixed> $replace
*/
protected function fail(string $reason, array $replace = []): never
{
throw ValidationException::withMessages([
'url' => [__("rules.media.remote.{$reason}", $replace)],
]);
}
}