π‘ SSRF via PDF: A Silent Threat for React/Next.js Developers
Have you tested your PDF exports for SSRF vulnerabilities?
π Introduction
Many React + Next.js developers assume that if everything happens in the browser, theyβre safe.
But once your project includes PDF export (invoices, reports, analytics), a headless browser comes into play on the server side.
Key question: What happens if that browser can make requests on behalf of your server?
π Fresh Stats & Sources
- Actively Exploiting Multiple SSRF Vulnerabilities. 400+ IPs Actively Exploiting Multiple SSRF Vulnerabilities In The Wild
- OWASP Top 10 (A10:2021): SSRF is βlow prevalence but high impact.β OWASP A10:2021 β Server-Side Request Forgery (SSRF)
π§ͺ Live PoC with webhook.site
- Open https://webhook.site and copy your unique URL.
- Send this HTML to your PDF rendering endpoint:
<iframe src="https://webhook.site/YOUR-UUID"></iframe>
- On https://webhook.site, you'll see a request from HeadlessChrome proof that SSRF is happening.
β What NOT to Do (Vulnerable Example)
// pages/api/generate-pdf.ts
import puppeteer from 'puppeteer'
export default async function handler(req, res) {
const { htmlContent } = req.body
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(htmlContent, { waitUntil: 'networkidle0' }) // β οΈ No sanitization
const pdf = await page.pdf()
await browser.close()
res.setHeader('Content-Type', 'application/pdf')
res.send(pdf)
}
Issues:
- Injected HTML is not sanitized.
- No request blocking.
- Dangerous vectors include
<iframe>
and CSS imports:
@import url("http://169.254.169.254/latest/meta-data/");
β
How to Do It Right (Next.js + TS)
import puppeteer from 'puppeteer'
import DOMPurify from 'isomorphic-dompurify'
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { htmlContent } = req.body as { htmlContent: string }
// 1. SANITIZE HTML
const cleanHtml = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['b', 'i', 'p', 'strong', 'em', 'u', 'br'],
ALLOWED_ATTR: []
})
const browser = await puppeteer.launch()
const page = await browser.newPage()
// 2. BLOCK UNWANTED REQUESTS (FE-critical!)
page.on('request', (request) => {
if (isCloudMetadata(request.url())) request.abort() // π
else request.continue()
})
await page.setRequestInterception(true)
const isCloudMetadata = (url: string) =>
url.includes('169.254.169.254') || url.includes('localhost') || url.startsWith('file://')
// 3. CSP
await page.setExtraHTTPHeaders({
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self';"
})
await page.setContent(cleanHtml, { waitUntil: 'networkidle0' })
const pdf = await page.pdf()
await browser.close()
res.setHeader('Content-Type', 'application/pdf')
res.send(pdf)
}
π No-Browser Alternative
import { PDFDocument } from 'pdf-lib'
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const pdfDoc = await PDFDocument.create()
const page = pdfDoc.addPage([595, 842]) // A4
page.drawText('Hello, secure PDF!', { x: 50, y: 790, size: 16 })
const pdfBytes = await pdfDoc.save()
res.setHeader('Content-Type', 'application/pdf')
res.send(Buffer.from(pdfBytes))
}
Benefit: No headless browser β no network requests.
πΌ Attack Diagram
[ User π¨βπ» ]
|
| POST htmlContent with <iframe src="http://169.254.169.254/">
v
[ Next.js API β‘ ]
|
| Render HTML via Puppeteer
v
[ Headless Chrome π ]
|
| HTTP/file request to internal resources:
| - http://169.254.169.254/latest/meta-data/
| - http://localhost:8080/admin
| - file:///etc/passwd
v
[ AWS βοΈ / Internal Data ]
|
| Response embedded in PDF
v
[ User downloads PDF π ]
π Key SSRF Breaches & Reports
π¦ Capital One (2019)
- Attack Vector: SSRF β AWS IMDSv1 exploitation
- Impact: 106 million records compromised
- References:
π Pentest Case (2024)
- Scenario: PDF export functionality β Internal API access
- Technical Breakdown: CyberAdvisors Blog Post
π° HackerOne Report #2262382
- Severity: Critical SSRF (AWS metadata exposure)
- Bounty: $10,000
- Report: HackerOne Disclosure
π‘οΈ SSRF Prevention Matrix
Tactic | Implementation | Effort |
---|---|---|
Request Blocking | page.setRequestInterception |
Low |
HTML Sanitization | DOMPurify |
Medium |
Browser Elimination | pdf-lib |
High |
π‘ Monitoring with Sentry
import * as Sentry from '@sentry/node';
page.on('request', req => {
if (req.url().includes('169.254.169.254')) {
Sentry.captureMessage(`SSRF ALERT: ${req.url()}`);
req.abort();
} else {
req.continue();
}
});
π Your Task
- Implement request blocking
- Run webhook.site test
- Share results in comments!
π Follow for More
I'm writing more about:
- Frontend security for React/Next.js devs
- Real-world web exploit mitigation
- CSP, SSRF, XSS, and modern browser defences
Top comments (0)