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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}>
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user