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>
150 lines
4.9 KiB
PHP
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)],
|
|
]);
|
|
}
|
|
}
|