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.
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 mediaurldirectly viaHttp::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: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
urlis validated only for shape, never for target safety:app/Support/PostMediaRules.php::rules():Laravel's
url:http,httpsrule validates well-formedness, not target reachability - it does not resolve DNS or block private/reserved/loopback ranges, sohttp://169.254.169.254/latest/meta-data/orhttp://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
urlto 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, readSafeHttpFetcher.phpin full, confirmed its use inBlueskyPublisher.php:207versus its absence at line 300 in the same file, and grepped everyServices/Social/*Publisher.phpfor the sameHttp::get/Http::sinkpattern on a$mediaItem->url/$urlvalue, plusPostMediaRules.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) toPostMediaRulesfor both the hosted and API media-URL paths.