Merge pull request #7121 from shleeable/fix/status-remote-update-ssrf

Harden remote status update media fetch against SSRF
pull/7130/head
Shlee 2 weeks ago committed by GitHub
commit aa152372c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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;

@ -0,0 +1,132 @@
<?php
use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline;
use App\Models\Media;
use App\Models\Status;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| StatusRemoteUpdatePipeline SSRF hardening
|--------------------------------------------------------------------------
|
| updateMedia() re-fetches remote attachments by issuing a server-side HEAD to
| the attacker-controlled attachment[*].url. The URL must be validated and
| fetched through the SSRF-hardened path (SecureMediaFetchService), so it can
| never target internal / loopback / link-local / non-https addresses.
|
*/
function remoteNoteStatus(): Status
{
$user = User::factory()->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');
});
Loading…
Cancel
Save