The Security of Ephemeral Pages

speckx1 pts0 comments

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...

content security self abuse policy none

Related Articles