mirror of https://github.com/pixelfed/pixelfed
Merge pull request #7134 from shleeable/fix/storyfetch-ssrf
Route StoryFetch outbound requests through SSRF-hardened fetch servicepull/7143/head
commit
0fc121b685
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
use App\Services\SecureMediaFetchService;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SecureMediaFetchService header passthrough + redirect safety
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| get() accepts caller headers (e.g. an Authorization bearer for bearcap
|
||||
| fetches) but must forward them only to the original host and strip them on
|
||||
| cross-origin redirects. Redirects to private/reserved addresses must be
|
||||
| refused (never connected to).
|
||||
|
|
||||
*/
|
||||
|
||||
function seedPublicIp(string $host): void
|
||||
{
|
||||
Cache::put('helpers:url:public-ips:'.hash('xxh128', $host), ['203.0.113.50'], 3600);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('forwards caller headers to the origin host', function () {
|
||||
seedPublicIp('origin.example');
|
||||
|
||||
Http::fake([
|
||||
'https://origin.example/story' => Http::response('{"ok":true}', 200, [
|
||||
'Content-Type' => 'application/json',
|
||||
]),
|
||||
]);
|
||||
|
||||
$body = SecureMediaFetchService::get('https://origin.example/story', null, null, [
|
||||
'Authorization' => 'Bearer secret-token',
|
||||
]);
|
||||
|
||||
expect($body)->toBe('{"ok":true}');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://origin.example/story'
|
||||
&& $request->hasHeader('Authorization', 'Bearer secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips the Authorization header on a cross-origin redirect', function () {
|
||||
seedPublicIp('origin.example');
|
||||
seedPublicIp('other.example');
|
||||
|
||||
Http::fake([
|
||||
'https://origin.example/story' => Http::response('', 302, [
|
||||
'Location' => 'https://other.example/story',
|
||||
]),
|
||||
'https://other.example/story' => Http::response('{"ok":true}', 200, [
|
||||
'Content-Type' => 'application/json',
|
||||
]),
|
||||
]);
|
||||
|
||||
$body = SecureMediaFetchService::get('https://origin.example/story', null, null, [
|
||||
'Authorization' => 'Bearer secret-token',
|
||||
]);
|
||||
|
||||
expect($body)->toBe('{"ok":true}');
|
||||
|
||||
// The cross-origin hop must NOT carry the bearer token.
|
||||
Http::assertSent(function ($request) {
|
||||
if ($request->url() !== 'https://other.example/story') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $request->hasHeader('Authorization');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to follow a redirect to a private address', function () {
|
||||
seedPublicIp('origin.example');
|
||||
|
||||
Http::fake([
|
||||
'https://origin.example/story' => Http::response('', 302, [
|
||||
'Location' => 'http://169.254.169.254/latest/meta-data/',
|
||||
]),
|
||||
// If the service (incorrectly) followed, this would answer; it must not.
|
||||
'169.254.169.254/*' => Http::response('SECRET', 200),
|
||||
]);
|
||||
|
||||
$body = SecureMediaFetchService::get('https://origin.example/story');
|
||||
|
||||
expect($body)->toBeFalse();
|
||||
|
||||
Http::assertNotSent(function ($request) {
|
||||
return str_contains($request->url(), '169.254.169.254');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\StoryPipeline\StoryFetch;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| StoryFetch SSRF hardening
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Both outbound fetches (bearcap story JSON and media download) route through
|
||||
| SecureMediaFetchService, which refuses to follow redirects to private /
|
||||
| reserved addresses. These tests exercise the payload-fetch sink directly.
|
||||
|
|
||||
*/
|
||||
|
||||
function seedPublicIpForStory(string $host): void
|
||||
{
|
||||
Cache::put('helpers:url:public-ips:'.hash('xxh128', $host), ['203.0.113.60'], 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the private fetchStoryPayload() via reflection.
|
||||
*/
|
||||
function callFetchStoryPayload(string $url, string $token)
|
||||
{
|
||||
$job = new StoryFetch([]);
|
||||
$ref = new ReflectionMethod($job, 'fetchStoryPayload');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
return $ref->invoke($job, $url, $token);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('fetches the story payload over the hardened path', function () {
|
||||
seedPublicIpForStory('peer.example');
|
||||
|
||||
Http::fake([
|
||||
'https://peer.example/story' => Http::response('{"id":"https://peer.example/s/1"}', 200, [
|
||||
'Content-Type' => 'application/json',
|
||||
]),
|
||||
]);
|
||||
|
||||
$payload = callFetchStoryPayload('https://peer.example/story', 'bearcap-token-1234567890');
|
||||
|
||||
expect($payload)->toBeArray()
|
||||
->and($payload['id'])->toBe('https://peer.example/s/1');
|
||||
});
|
||||
|
||||
it('refuses a payload fetch that redirects to a private address', function () {
|
||||
seedPublicIpForStory('peer.example');
|
||||
|
||||
Http::fake([
|
||||
'https://peer.example/story' => Http::response('', 302, [
|
||||
'Location' => 'http://169.254.169.254/latest/meta-data/',
|
||||
]),
|
||||
'169.254.169.254/*' => Http::response('SECRET', 200),
|
||||
]);
|
||||
|
||||
$payload = callFetchStoryPayload('https://peer.example/story', 'bearcap-token-1234567890');
|
||||
|
||||
expect($payload)->toBeNull();
|
||||
|
||||
Http::assertNotSent(function ($request) {
|
||||
return str_contains($request->url(), '169.254.169.254');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue