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\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; 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 * - 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. * 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. * - 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 class RemoteImage
{ {
@@ -63,6 +68,7 @@ class RemoteImage
)]], )]],
]) ])
->withHeaders(['Accept' => 'image/*']) ->withHeaders(['Accept' => 'image/*'])
->withUserAgent($this->userAgent())
->get($url); ->get($url);
} catch (ConnectionException) { } catch (ConnectionException) {
$this->fail('unreachable'); $this->fail('unreachable');
@@ -94,6 +100,19 @@ class RemoteImage
return $contents; 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 * The address to connect to, once every address the host has is known to
* be public. * be public.

View File

@@ -25,7 +25,15 @@
# second line: opened directly rather than through an <img>, an SVG is a # second line: opened directly rather than through an <img>, an SVG is a
# document, and nothing stored here has any business running script or # document, and nothing stored here has any business running script or
# loading anything from elsewhere. # loading anything from elsewhere.
@svg path /storage/*.svg #
# Two matchers, which Caddy ANDs, rather than `path /storage/*.svg`: a `*`
# in the middle of a path does not cross a `/`, so that pattern only ever
# matched files directly under /storage — and every upload is a directory
# below it.
@svg {
path /storage/*
path_regexp (?i)\.svg$
}
header @svg Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:" header @svg Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
# The frontend is a separate application on another origin. # The frontend is a separate application on another origin.

View File

@@ -133,6 +133,26 @@ class MediaUploadTest extends TestCase
->assertJsonPath('data.width', 30); ->assertJsonPath('data.width', 30);
} }
#[Test]
public function it_says_who_is_fetching(): void
{
config()->set('app.name', 'Pireon Admin');
config()->set('app.url', 'https://admin.example.org');
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
Http::fake([
'https://images.example.test/*' => Http::response($this->png(), 200),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])->assertCreated();
// Hosts such as Wikimedia answer Guzzle's anonymous default with 403.
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://images.example.test/')
&& $request->header('User-Agent') === ['pireon-admin/1.0 (+https://admin.example.org; media fetcher)']);
}
/** /**
* @return array<string, array{0: array<string, array<int, string>>, 1: string}> * @return array<string, array{0: array<string, array<int, string>>, 1: string}>
*/ */