From 856f2f8f2d2225ab7df64aa0e2d9d08a8ca8ce7a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 9 Sep 2026 19:04:03 +0930 Subject: [PATCH] Harden remote status update media fetch against SSRF --- .../StatusRemoteUpdatePipeline.php | 27 ++-- .../Federation/StatusRemoteUpdateSsrfTest.php | 132 ++++++++++++++++++ 2 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Federation/StatusRemoteUpdateSsrfTest.php diff --git a/app/Jobs/StatusPipeline/StatusRemoteUpdatePipeline.php b/app/Jobs/StatusPipeline/StatusRemoteUpdatePipeline.php index 39ae9e4ab..d3caf7f84 100644 --- a/app/Jobs/StatusPipeline/StatusRemoteUpdatePipeline.php +++ b/app/Jobs/StatusPipeline/StatusRemoteUpdatePipeline.php @@ -8,13 +8,14 @@ use App\Models\Profile; use App\Models\Status; use App\Models\StatusEdit; use App\Services\SanitizeService; +use App\Services\SecureMediaFetchService; use App\Services\StatusService; +use App\Util\ActivityPub\Helpers; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Purify; @@ -117,13 +118,23 @@ class StatusRemoteUpdatePipeline implements ShouldQueue ]); $nm->each(function ($n, $key) use ($status) { - $res = Http::withOptions(['allow_redirects' => false])->retry(3, 100, throw: false)->head($n['url']); + // Validate the attacker-controlled attachment URL before issuing any + // server-side request. This rejects http://, IP-literal, and + // (with DNS checks) private-resolving hosts, closing the SSRF sink. + $url = Helpers::validateUrl($n['url']); + if (! $url) { + return; + } - if (! $res->successful()) { + // Hardened HEAD: validate + resolve public IPs + pin the connection + // (CURLOPT_RESOLVE) + re-validate every redirect hop + byte cap. + // Matches the SSRF hardening applied to every other remote-media sink. + $res = SecureMediaFetchService::head($url); + if ($res === false) { return; } - if (! in_array($res->header('content-type'), explode(',', config_cache('pixelfed.media_types')))) { + if (! in_array($res['mime'], explode(',', config_cache('pixelfed.media_types')))) { return; } @@ -131,11 +142,11 @@ class StatusRemoteUpdatePipeline implements ShouldQueue $m->status_id = $status->id; $m->profile_id = $status->profile_id; $m->remote_media = true; - $m->media_path = $n['url']; - $m->mime = $res->header('content-type'); - $m->size = $res->hasHeader('content-length') ? $res->header('content-length') : null; + $m->media_path = $url; + $m->mime = $res['mime']; + $m->size = $res['length'] ?? null; $m->caption = isset($n['name']) && ! empty($n['name']) ? Purify::clean($n['name']) : null; - $m->remote_url = $n['url']; + $m->remote_url = $url; $m->blurhash = isset($n['blurhash']) && (strlen($n['blurhash']) < 50) ? $n['blurhash'] : null; $m->width = isset($n['width']) && ! empty($n['width']) ? $n['width'] : null; $m->height = isset($n['height']) && ! empty($n['height']) ? $n['height'] : null; diff --git a/tests/Feature/Federation/StatusRemoteUpdateSsrfTest.php b/tests/Feature/Federation/StatusRemoteUpdateSsrfTest.php new file mode 100644 index 000000000..2a9d6288b --- /dev/null +++ b/tests/Feature/Federation/StatusRemoteUpdateSsrfTest.php @@ -0,0 +1,132 @@ +create(); + $user->refresh(); + + $objectUrl = 'https://remote.example/users/bob/statuses/1'; + + $status = Status::factory()->create([ + 'profile_id' => $user->profile_id, + 'type' => 'photo', + 'scope' => 'public', + 'local' => false, + 'uri' => $objectUrl, + 'object_url' => $objectUrl, + ]); + + return $status; +} + +function updateActivityWithAttachmentUrl(string $objectUrl, string $attachmentUrl): array +{ + return [ + 'id' => $objectUrl, + 'type' => 'Update', + 'content' => 'updated caption', + 'attachment' => [ + [ + 'type' => 'Image', + 'mediaType' => 'image/jpeg', + 'url' => $attachmentUrl, + ], + ], + ]; +} + +it('does not issue a server-side request to a link-local metadata address', function () { + Http::fake(); + + $status = remoteNoteStatus(); + $activity = updateActivityWithAttachmentUrl( + $status->object_url, + 'http://169.254.169.254/latest/meta-data/' + ); + + (new StatusRemoteUpdatePipeline($activity))->handle(); + + Http::assertNothingSent(); + expect(Media::whereStatusId($status->id)->whereRemoteUrl('http://169.254.169.254/latest/meta-data/')->exists())->toBeFalse(); +}); + +it('does not issue a server-side request to a loopback address', function () { + Http::fake(); + + $status = remoteNoteStatus(); + $activity = updateActivityWithAttachmentUrl( + $status->object_url, + 'http://127.0.0.1:9000/internal' + ); + + (new StatusRemoteUpdatePipeline($activity))->handle(); + + Http::assertNothingSent(); +}); + +it('rejects an https URL whose host is a private IP literal', function () { + Http::fake(); + + $status = remoteNoteStatus(); + $activity = updateActivityWithAttachmentUrl( + $status->object_url, + 'https://10.0.0.1/admin.jpg' + ); + + (new StatusRemoteUpdatePipeline($activity))->handle(); + + Http::assertNothingSent(); +}); + +it('persists media for a valid https attachment via the hardened HEAD path', function () { + // Pre-seed the DNS resolution cache so the hardened fetch treats the host as + // publicly resolvable without a real network lookup, keeping the test + // deterministic. + Cache::put( + 'helpers:url:public-ips:'.hash('xxh128', 'media.example'), + ['203.0.113.20'], + 3600 + ); + + Http::fake([ + 'https://media.example/photo.jpg' => Http::response('', 200, [ + 'Content-Type' => 'image/jpeg', + 'Content-Length' => '50000', + ]), + ]); + + $status = remoteNoteStatus(); + $activity = updateActivityWithAttachmentUrl( + $status->object_url, + 'https://media.example/photo.jpg' + ); + + (new StatusRemoteUpdatePipeline($activity))->handle(); + + $media = Media::whereStatusId($status->id)->whereRemoteUrl('https://media.example/photo.jpg')->first(); + + expect($media)->not->toBeNull() + ->and($media->mime)->toBe('image/jpeg'); +});