Skip to content

Unauthenticated full-read SSRF in the /cgi/image, /cgi/video and /cgi/asset proxy routes on self-hosted Webstudio (raw fetch of an attacker URL when RESIZE_ORIGIN is unset) #5816

Description

@geo-chen

Summary

The Webstudio builder serves three unauthenticated proxy routes, GET /cgi/image/<name>, GET /cgi/video/<name> and GET /cgi/asset/<name>. When the RESIZE_ORIGIN environment variable is not set (the default for a self-hosted deployment; the hosted SaaS sets it and is not affected), and <name> is an absolute URL, the loader returns fetch(name) directly to the caller. There is no scheme allowlist, no private-IP/loopback/metadata block, and no authentication. The only guard on the image/asset routes is a Referer host check that is trivially bypassed (omit the Referer header, or set it to the target host, since it is a client-controlled header); the video route has no referer check at all. As a result, any unauthenticated client can use a self-hosted Webstudio instance as a full-read SSRF proxy to reach cloud metadata services and internal-only hosts and read the responses.

Details

apps/builder/app/routes/cgi.image.$.ts:

// Allow direct image access, and from the same origin
const refererRawUrl = request.headers.get("referer");
const refererUrl = refererRawUrl === null ? url : new URL(refererRawUrl);  // no referer -> uses url (passes)
if (refererUrl.host !== url.host) {
  throw new Response("Forbidden", { status: 403 });
}

if (env.RESIZE_ORIGIN !== undefined) {
  ...                                  // hosted path: fetch RESIZE_ORIGIN + loader href (safe)
  return responseWHeaders;
}

// support absolute urls locally
if (URL.canParse(name)) {
  return fetch(name);                  // <-- SSRF: arbitrary URL, response returned to caller
}

The Referer check fails open: when the header is absent refererUrl is set to the request URL itself, so refererUrl.host === url.host and the check passes; and because Referer is attacker-controlled, it can simply be set to the instance host. So the check stops naive cross-site <img> embedding from a browser but not a direct attacker. The fetch(name) branch is reached whenever RESIZE_ORIGIN is undefined, which is the normal state of a self-hosted instance that has not wired up a Cloudflare-compatible image-resize origin.

apps/builder/app/routes/cgi.asset.$.ts is identical (referer check, then if (URL.canParse(name)) return fetch(name)), and apps/builder/app/routes/cgi.video.$.ts is worse, it has no referer check at all:

if (env.RESIZE_ORIGIN !== undefined) { ... }
if (URL.canParse(name)) {
  return fetch(name);                  // <-- SSRF, no referer guard
}

None of the three routes import any authentication/session helper; they are public loaders. name is decodePathFragment(url.pathname.slice("/cgi/image/".length)), i.e. everything after the prefix, so a full URL placed there is fetched verbatim. The fetched Response is returned to the client, so this is a full-read SSRF (the attacker sees the response body), not a blind one.

PoC

(available upon request, tested against current main - commit f52545a)

Impact

On a self-hosted Webstudio instance without RESIZE_ORIGIN configured, an unauthenticated attacker turns the server into a full-read SSRF proxy: read cloud metadata (IAM credentials, tokens), reach internal-only services and admin panels not exposed to the internet, and port-scan the internal network, with the responses returned verbatim. Confidentiality impact is high (credential/secret disclosure from internal endpoints). The hosted webstudio.is SaaS, which sets RESIZE_ORIGIN, is not affected. Fix: never fetch(name) on an unvalidated URL; restrict the absolute-URL branch to an allowlist of asset hosts (or remove it), block private/loopback/link-local/metadata IP ranges after DNS resolution (and re-check on redirect), restrict the scheme to http(s), and do not rely on the spoofable Referer header as an access control.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions