Render Untrusted HTML as Screenshots: Safe Playwright Pipeline
- Timeout (seconds)
- 30 s
- You entered
- Viewport width
- 1280 px
- You entered
- Viewport height
- 800 px
- You entered
Yes, you can safely render untrusted HTML as screenshots using a Playwright pipeline, provided you isolate the browser process and treat the HTML as untrusted input. This article explains the core safety measures, a working pipeline design, and the trade-offs involved, so you can decide if this approach fits your use case.
Formula
The calculator estimates how many screenshots you can take per minute given a timeout per screenshot. The formula is:
shots_per_minute = 60 / timeout_seconds
- timeout_seconds – the maximum time you allow for a single screenshot render (seconds).
- viewport_width – the width of the browser viewport in pixels (used for sizing, not in the formula).
- viewport_height – the height of the browser viewport in pixels (used for sizing, not in the formula).
This is a simple throughput estimate. In practice, the actual rate will be lower due to page load time, network requests, and rendering overhead.
Worked Example
Using the defaults from the calculator: timeout = 30 seconds, viewport = 1280×800.
- Convert timeout to minutes: 30 seconds = 0.5 minutes.
- Divide 60 by 0.5: 60 / 0.5 = 120 screenshots per minute.
The calculator shows 120 screenshots per minute. That is the theoretical maximum if every render completes in exactly 30 seconds. In reality, you will get fewer because of startup time, network latency, and page complexity.
Why Screenshot Rendering Is Safer Than DOM Parsing
When you render untrusted HTML in a browser and take a screenshot, you are not parsing the HTML in your server-side code. The HTML is executed inside a sandboxed browser process. This means that many classic server-side attacks (like XXE, SQL injection, or command injection via template strings) are not applicable, because the HTML is never passed to a server-side parser. Instead, the browser handles the HTML and JavaScript, and the output is an image.
However, the browser is still a complex piece of software. A malicious HTML page could attempt to exploit a browser vulnerability, access local files (if the browser is not sandboxed), or make network requests to internal services. The key is to minimize the attack surface and contain the damage.
Core Safety Measures for a Playwright Pipeline
To safely render untrusted HTML, you need to implement several layers of defense:
- Use a dedicated browser context – Playwright allows you to create a new browser context for each screenshot. This isolates cookies, local storage, and other state. Never reuse a context across different untrusted inputs.
- Disable JavaScript or restrict it – If the HTML does not require JavaScript, disable it entirely. If you must allow JavaScript, use Playwright’s
routemethod to block requests to internal IPs, localhost, and file:// URLs. Also block requests to known malicious domains if needed. - Run the browser in a sandbox – On Linux, run the browser with the
--no-sandboxflag disabled (i.e., keep the sandbox on). Use a dedicated system user with minimal permissions, and consider using a container (Docker) or a virtual machine for extra isolation. - Set a short timeout – Always set a navigation timeout and a screenshot timeout. A malicious page could hang or loop forever. Playwright’s
page.screenshothas a timeout option, and you can also usepage.setDefaultTimeout. - Limit viewport and resources – Set a reasonable viewport size (e.g., 1280×800) and disable images, fonts, and other resources if they are not needed. This reduces the attack surface and speeds up rendering.
- Use a separate process or container – If you are rendering many screenshots, consider using a worker pool where each worker is a separate process. This way, a crash or exploit in one worker does not affect the main application.
- Validate the output – After taking the screenshot, verify that it is a valid image and that it is not blank or too large. This can help detect if the page was blocked or if the browser crashed.
Building the Pipeline: Step-by-Step
Here is a concrete pipeline using Playwright (Node.js example):
- Install Playwright –
npm install playwrightand thennpx playwright installto download the browser binaries. - Launch a browser instance – Use
chromium.launch()with theargsoption to enable sandboxing. On Linux, you may need to install dependencies. - Create a new context – Use
browser.newContext()withviewportanduserAgentoptions. You can also setjavaScriptEnabled: falseif you do not need JS. - Set up request blocking – Use
context.route('**/*', route => { ... })to abort requests to internal IPs, localhost, or file:// URLs. Also block images and fonts if not needed. - Navigate to a data URL or a local server – You can use
page.goto('data:text/html,' + encodeURIComponent(html))to render the HTML directly without a web server. This is faster and avoids network issues. - Wait for the page to render – Use
page.waitForTimeout()or better, wait for a specific selector or network idle. For untrusted HTML, a fixed timeout is safer. - Take the screenshot – Use
page.screenshot({ path: 'output.png' })or get a buffer. - Close the context – Always close the context after the screenshot to free resources.
Here is a minimal code snippet:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ args: ['--no-sandbox'] }); // note: keep sandbox on for production!
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
await page.route('**/*', route => {
const url = route.request().url();
if (url.startsWith('file://') || url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1')) {
return route.abort();
}
return route.continue();
});
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 10000 });
await page.screenshot({ path: 'screenshot.png' });
await context.close();
await browser.close();
})();
Note: In production, do not use --no-sandbox. That flag disables the Chromium sandbox and is only for containers that cannot run it. Instead, run the browser as a non-root user with the sandbox enabled.
Common Problems and How to Avoid Them
- Blank screenshots – If the page takes too long to load, you may get a blank image. Set a reasonable timeout and wait for a specific element if possible.
- Memory leaks – If you create a new browser for each screenshot, it will be slow. Instead, reuse a browser instance but create new contexts. Contexts are cheap.
- Resource exhaustion – Malicious HTML could try to allocate huge amounts of memory. Use
page.setViewportSizeto limit the viewport, and optionally use--disable-dev-shm-usagein the browser args to avoid shared memory issues. - Network requests to internal services – Always block requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and localhost. This prevents SSRF attacks.
- JavaScript-based attacks – If you must enable JavaScript, consider using a library like
jsdomfor simple DOM manipulation, but for full rendering, you need a real browser. In that case, use a sandboxed environment.
Comparing with Alternative Approaches
There are other ways to render HTML to images:
- Headless Chrome directly – You can use the Chrome DevTools Protocol (CDP) without Playwright. Playwright is a higher-level API that simplifies this.
- wkhtmltoimage – This uses an older WebKit engine. It is less secure because it may not be sandboxed as well, and it does not support modern CSS.
- Puppeteer – Similar to Playwright but only for Chrome. Playwright supports multiple browsers and has better isolation features.
- Server-side rendering to SVG – If you only need simple text and boxes, you can convert HTML to SVG using a library like
html-to-svg, but this does not handle complex CSS or JavaScript.
Playwright is often preferred because it is actively maintained, supports multiple browsers, and has a clean API for handling contexts and routes.
Costs and Performance Considerations
Rendering screenshots is CPU and memory intensive. Each browser context uses about 50-100 MB of memory. If you need to render many screenshots, you will need to scale horizontally. Using a pool of worker processes can help, but you must ensure that each worker is isolated.
In terms of time, a simple HTML page with no external resources can render in under a second. A complex page with many scripts and images can take several seconds. The calculator above gives a rough throughput estimate, but you should benchmark with your actual workload.
Security Best Practices and Limitations
Even with all precautions, no system is 100% secure. The browser is a large attack surface, and zero-day vulnerabilities could be exploited. Therefore, you should:
- Run the rendering service in a separate network – Do not place it on the same network as your internal services.
- Use a read-only filesystem – Mount the output directory as a volume, and make the rest of the filesystem read-only.
- Monitor for anomalies – Watch for high memory usage, unusual network requests, or crashes.
- Keep the browser and Playwright up to date – Security patches are released regularly.
One limitation is that you cannot fully prevent all side effects. For example, a page could make a request to a public URL that triggers an action (like a webhook). To mitigate this, you can block all requests except to a whitelist, or use a proxy that filters outbound traffic.
Frequently Asked Questions
Can I disable JavaScript entirely?
Yes, you can set javaScriptEnabled: false in the browser context. This is the safest option if the HTML does not require JavaScript. It also speeds up rendering and reduces the attack surface.
What if the HTML contains external images or fonts?
You can block those requests using route.abort() for image and font resource types. This will make the screenshot render without those resources, which may be acceptable for your use case. Alternatively, you can allow them but restrict to HTTPS only.
How do I handle pages that take too long to load?
Set a timeout for page.goto() or page.setContent(). If the timeout is exceeded, Playwright will throw an error, and you can catch it and take a screenshot of the error page or skip it.
Is it safe to run this in a Docker container?
Yes, Docker adds an extra layer of isolation. However, you must ensure that the container runs as a non-root user and that the browser’s sandbox is enabled. You may need to install additional libraries for the browser to run in the container.
Can I use this to render HTML from user input on a public website?
Yes, but you must be very careful. In addition to the technical measures, you should implement rate limiting and content size limits to prevent abuse. Never render HTML that contains personal data or secrets, as the screenshot could be leaked.
What about PDF rendering instead of screenshots?
Playwright can also generate PDFs from HTML. The same safety measures apply. PDFs are vector-based and can be more useful for printing, but they may also contain embedded scripts (though modern PDF viewers disable them). Screenshots are simpler and have a smaller attack surface.
Related
Formula used
in t | Timeout (seconds) | s | 30 | 0 in w | Viewport width | px | 1280 | 0 in h | Viewport height | px | 800 | 0 out shots | Screenshots per minute | 1/min | 60 / t | 1
Each line is one input (in, sel, list) or one result (out: label, unit, formula, decimals). How the calculators work
Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.