Skip to content

Security: publish-time media download bypasses the app's own SSRF guard (SafeHttpFetcher) #365

Description

@kta1kri

Summary

The app has a purpose-built SSRF guard, App\Services\Brand\SafeHttpFetcher (its own doc comment states "All outbound requests to user-supplied URLs must go through here"), correctly used for link-preview/brand-autofill fetches - but every publish-time media downloader across the social-platform publishers fetches the post's client-supplied media url directly via Http::get()/Http::sink(), bypassing that guard entirely. Any authenticated workspace member (including the lowest posting role) can schedule a post with a media URL pointing at an internal service or a cloud metadata endpoint; the server fetches it server-side and, on success, treats the response as image/video data.

Where

The clearest sibling-asymmetry evidence is inside a single file, app/Services/Social/BlueskyPublisher.php:

// line 207 - link-card image fetch, correctly guarded
app(SafeHttpFetcher::class)->guardAgainstSsrf($card->imageUrl);

// line 300 - media-for-publishing fetch, NOT guarded
$response = Http::withOptions($options)->timeout($timeoutSeconds)->get($url);

The same unguarded pattern repeats across nearly every publisher: XPublisher.php:158 (Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url)), AbstractLinkedInPublisher.php:534, MastodonPublisher.php:77, TikTokPublisher.php:314, YouTubePublisher.php:103, PinterestPublisher.php:142/242.

The source of url is validated only for shape, never for target safety: app/Support/PostMediaRules.php::rules():

'media.*.url' => $hosted
    ? ['required', 'string', 'max:2048']                        // web: no format check at all
    : ['required', 'string', 'max:2048', 'url:http,https'],     // API: syntax-only, no SSRF check

Laravel's url:http,https rule validates well-formedness, not target reachability - it does not resolve DNS or block private/reserved/loopback ranges, so http://169.254.169.254/latest/meta-data/ or http://127.0.0.1:PORT/... both pass this validation cleanly.

Impact

Any authenticated workspace member with permission to create/schedule a post can set a media item's url to an internal/private-network or cloud-metadata address. When the scheduled post is published, the relevant publisher's downloader (Http::get()/Http::sink(), up to a 600-second timeout in the X path) fetches that URL server-side with no SSRF protection at all. If the target responds with content that the publisher's mime-sniffing accepts as an image/video, the fetched bytes are uploaded to the attacker-controlled social account as part of the post - turning this into a viable internal-network content-exfiltration path (not just a blind connectivity probe), reachable by the lowest-privileged posting role in the workspace.

What I did and did not do

Source-level analysis only (cloned HEAD ae0fa9e, read SafeHttpFetcher.php in full, confirmed its use in BlueskyPublisher.php:207 versus its absence at line 300 in the same file, and grepped every Services/Social/*Publisher.php for the same Http::get/Http::sink pattern on a $mediaItem->url/$url value, plus PostMediaRules.php's validation rules confirming no SSRF-aware check exists on either the web or API media-URL input path). I did not run a live instance, schedule a post with a metadata-endpoint URL, and confirm actual content retrieval/exfiltration end to end - the missing-guard code path is unambiguous from source and from the direct same-file contrast with the correctly-guarded link-card fetch, but I have not dynamically fired the exploit.

Separately, while reviewing this codebase I found closed PR #359 ("DO NOT MERGE: TPX-03 reference fix... media id existence check"), which documents a different, already-known-internally cross-tenant media IDOR that appears still unmerged on current main - I am not reporting that one here since your team is already aware of it; flagging only so you know it's still open in case this report reaches whoever triages security items.

Suggested fix

Route every publish-time media download through SafeHttpFetcher (or an equivalent guard) the same way the link-card/brand-autofill fetchers already do, and add a genuine SSRF-aware check (resolve and validate the target IP, not just URL syntax) to PostMediaRules for both the hosted and API media-URL paths.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions