The Security of Ephemeral Pages - Schalk Neethling - Open Web Engineer
Skip to navigation<br>Skip to main content<br>I built a little webapp, or micro-service, depending on how you like to think about it. Because of what it does, security was never an afterthought; it was a genuine concern from the start. The surface area for abuse is real: an app that accepts and serves arbitrary HTML from the public internet is exactly the kind of thing that attracts unwanted attention.
After the initial development and design phases were complete, I shifted focus to a structured security review. I have a security agent skill that I have been refining, and this felt like a good opportunity to put it to the test. I asked Codex to review the application using the skill as a reference. I also made a point of telling it not to feel constrained by what the skill covered — if it spotted something else, I wanted to know about it.
The rest of this article walks through the vulnerabilities flagged and the mitigations put in place as a result.
Uploaded HTML is directly executable on the app origin
This was the critical issue that needed immediate attention.
Issue: /api/pages/:id/content returns raw uploaded HTML as text/html from the primary app origin. The normal UI later injects CSP and loads it in a sandboxed blob iframe, but an attacker can share the direct API URL and bypass that isolation.
Risk: Stored same-origin XSS. Even though the app has little user state today, this enables phishing, origin abuse, future privilege escalation, and admin-targeted attacks.
Proposed fix: Add HTTP-level protection to content responses: at minimum Content-Security-Policy with sandbox allow-scripts plus the uploaded-page CSP, and X-Content-Type-Options: nosniff. Alternatively, return content as an attachment or move untrusted content to a separate origin.
The first step to address this was to ensure the page routes include the following headers:
Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"<br>Permissions-Policy = "camera=(), geolocation=(), microphone=(), payment=(), usb=()"<br>Referrer-Policy = "no-referrer"<br>X-Content-Type-Options = "nosniff"
Note : Permissions Policy is not yet widely available, so it will, as of this writing, May 15, 2026, only affect Chromium-based browsers.
The getPageContent function now returns the HTML with the following headers set:
"Content-Type": "text/html; charset=utf-8",<br>"Cache-Control": "no-store",<br>"X-Robots-Tag": "noindex",<br>"Content-Security-Policy": buildUploadedPageHttpCsp(),<br>"X-Content-Type-Options": "nosniff",<br>For the content security policy, we start with:
`sandbox allow-scripts; ${buildUploadedPageCsp()}`;<br>And then also add the result of buildUploadedPageCsp:
return [<br>"default-src 'none'",<br>`script-src ${scripts}`,<br>`style-src ${styles}`,<br>"font-src https://fonts.gstatic.com",<br>"img-src data: blob:",<br>"media-src data: blob:",<br>"object-src 'none'",<br>"base-uri 'none'",<br>"form-action 'none'",<br>].join("; ");<br>For script-src and style-src we set 'unsafe-inline' and allow the following content delivery networks:
const TRUSTED_CDN_ORIGINS = [<br>"https://cdn.jsdelivr.net",<br>"https://unpkg.com",<br>"https://cdnjs.cloudflare.com",<br>] as const;<br>From this list, only fonts.googleapis.com is permitted in style-src so the CSS can load, and https://fonts.gstatic.com is explicitly allowed for the font files themselves. Neither is a valid script source.
It is also important to note that an uploaded page is rendered inside a sandboxed iframe:
iframe<br>id="page-iframe"<br>class="page-iframe"<br>sandbox="allow-scripts"<br>title="${`Shared ephemeral page ${pageId}`}"<br>>iframe><br>Medium vulnerabilities
There were also a couple of medium-level vulnerabilities to address:
No upload/report abuse throttling
Issue : Anyone can repeatedly upload 2 MB pages and submit abuse reports.
Risk : Storage cost abuse, function invocation abuse, Netlify Forms spam, and moderation noise.
Proposed Fix : Add rate limits or quotas for upload/report actions, ideally per IP/user-agent fingerprint at the edge or function layer. Consider CAPTCHA/Turnstile only once abuse is real enough to justify UX friction.
The first item addressed was rate limiting. Whenever a route is called that should be rate-limited, this becomes the first check: should this request be allowed to proceed?
A quick aside. Part of how rate limiting works is based on a RATE_LIMIT_SECRET — an application secret generated with OpenSSL and exposed to the app via Netlify’s sensitive environment variables. The source of this secret is stored in a 1Password vault and guarded even for local development; it is read from 1Password using Varlock and never stored in plain text in a .env file.
Checking rate limits takes us on quite the journey, so bear with me. The first thing we must do is retrieve the rate limit...