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:
- 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.
- 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
- 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 */ }
}
- 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.
Summary
validateBrowserPreviewTarget()insrc/lib/security/urlSafety.tsvalidates the hostname string at parse time, but the browser control server navigates to the URL afterward. Two bypass vectors exist:127.0.0.1or169.254.169.254when the browser actually fetches it.Location
src/lib/security/urlSafety.ts(validateBrowserPreviewTarget)src/app/api/office/browser-preview/route.ts(GET handler)Technical Detail
After validation,
ensurePreviewTabnavigates the browser to the URL. The screenshot response includesscreenshot.url(the final URL after redirects), but it's never re-validated against the SSRF blocklist.Impact
On cloud deployments (AWS, GCP, Azure):
http://169.254.169.254/latest/meta-data/Suggested Fix
screenshot.urlagainstisPrivateOrLoopbackHostnamebefore returning the screenshot:Note
The
isPrivateIpv4function correctly covers169.254.x.x(link-local). The gap is purely that validation happens once at parse time and not again after navigation.