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>
This commit is contained in:
2026-09-28 20:43:59 +02:00
parent ac546eba2b
commit 7e672a5e62
3 changed files with 48 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ namespace App\Services\Media;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
@@ -25,6 +26,10 @@ use Illuminate\Validation\ValidationException;
* - 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
{
@@ -63,6 +68,7 @@ class RemoteImage
)]],
])
->withHeaders(['Accept' => 'image/*'])
->withUserAgent($this->userAgent())
->get($url);
} catch (ConnectionException) {
$this->fail('unreachable');
@@ -94,6 +100,19 @@ class RemoteImage
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.