Skip to content

Security: SSRF bypass in browser preview via DNS rebinding and redirect chains #36

Description

@ThankNIXlater

Summary

validateBrowserPreviewTarget() in src/lib/security/urlSafety.ts validates the hostname string at parse time, but the browser control server navigates to the URL afterward. Two bypass vectors exist:

  1. DNS rebinding: A hostname resolves to a public IP during validation, then to 127.0.0.1 or 169.254.169.254 when the browser actually fetches it.
  2. HTTP redirects: The target page returns a 302 redirect to an internal IP after the initial validation passes.

Location

  • src/lib/security/urlSafety.ts (validateBrowserPreviewTarget)
  • src/app/api/office/browser-preview/route.ts (GET handler)

Technical Detail

export const validateBrowserPreviewTarget = (value: string): URL => {
  const parsed = new URL(value);
  // Only checks hostname string - not the resolved IP
  if (isPrivateOrLoopbackHostname(parsed.hostname)) {
    throw new Error("Browser preview does not allow loopback or private-network targets.");
  }
  return parsed;
};

After validation, ensurePreviewTab navigates the browser to the URL. The screenshot response includes screenshot.url (the final URL after redirects), but it's never re-validated against the SSRF blocklist.

Impact

On cloud deployments (AWS, GCP, Azure):

  • Exfiltration of instance metadata credentials via http://169.254.169.254/latest/meta-data/
  • Port scanning of internal network from the gateway host
  • Access to internal services running on localhost

Suggested Fix

  1. Re-validate the final navigated URL from screenshot.url against isPrivateOrLoopbackHostname before returning the screenshot:
if (screenshot.url) {
  try {
    const finalUrl = new URL(screenshot.url);
    if (isPrivateOrLoopbackHostname(finalUrl.hostname)) {
      return NextResponse.json(
        { error: "Browser preview target redirected to a private network address." },
        { status: 400 }
      );
    }
  } catch { /* invalid URL - proceed with caution */ }
}
  1. For DNS rebinding, consider resolving the hostname to an IP and validating the IP directly before passing to the browser (requires a DNS lookup step).

Note

The isPrivateIpv4 function correctly covers 169.254.x.x (link-local). The gap is purely that validation happens once at parse time and not again after navigation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions