Back to write-ups Issue #1  / 

Hardening a static site

This site has no backend, no login and no database. It can still be attacked. Here's what could go wrong, what I changed, and how I tested it.

<script> sneak
The problemAny text that gets into the page as HTML can run code in a visitor's browser.
<img onerror> javascript: <svg onload>
The attackI fed classic XSS payloads into the site's own content to see what would run.
CSP blocked.
The fixRender everything as text, and let the browser enforce it with CSP and Trusted Types.

Why a static site still needs security

It's easy to think a static site has nothing to attack. There's no server code to exploit and no database to dump. But the page runs JavaScript in every visitor's browser, and that's where cross-site scripting (XSS) happens.

My site builds parts of the page from data: skills, certifications and the list of write-ups. The first version inserted that data with innerHTML. That's fine while I write all the data myself, but one bad title or link copied from somewhere else would run as code for everyone who opens the page.

What could go wrong

  • XSS through content: a write-up title like <img src=x onerror=...> runs as soon as the page renders it.
  • Malicious links: a javascript: or data: URL in a link runs code when someone clicks it.
  • Third-party code: fonts or scripts loaded from other domains can change, track visitors, or be compromised.
  • Reverse tabnabbing: a site opened in a new tab can redirect my original tab.

What I changed

1. Content is always text, never HTML. Every element is built with createElement and filled with textContent. The browser treats the data as text, so markup inside it is shown, not run.

2. A strict Content Security Policy. The browser only loads scripts, styles, fonts and images from my own site. Inline scripts, eval, plugins and forms are all blocked.

default-src 'none'; script-src 'self'; style-src 'self';
font-src 'self'; img-src 'self'; connect-src 'self';
object-src 'none'; base-uri 'none'; form-action 'none';
require-trusted-types-for 'script'; trusted-types 'none'

3. Trusted Types. The last two lines of the policy make the browser refuse any raw HTML assignment, such as innerHTML. Even if a future edit of mine reintroduced the old pattern, it would throw an error instead of creating a hole.

4. A URL allow-list. Every link goes through one function before it reaches the page. Only https: links and paths on my own site get through.

function safeUrl(value) {
  const u = new URL(value, location.href);
  if (u.protocol === "https:") return u.href;
  if (u.origin === location.origin) return u.href;
  return null; // javascript:, data:, anything else is dropped
}

5. No third parties. The fonts are self-hosted, and the site makes no requests to any other domain. There's no outside code to trust and no tracking of visitors.

6. The small things. Outbound links use rel="noopener noreferrer", the referrer policy is no-referrer, and /.well-known/security.txt tells people how to report a problem.

How I tested it

I put real payloads into the site's data, served it locally, and loaded it in Chromium with the policy active.

PayloadWhereResult
<img src=x onerror=...>Write-up titleShown as text
<script>...</script>Write-up tagShown as text
"><svg onload=...>Write-up summaryShown as text
javascript:...Write-up link and CV linkLink removed
data:text/html,...Write-up linkLink removed
Inline <script> in the HTMLPage sourceBlocked by CSP
eval()Site scriptBlocked by CSP
innerHTML assignmentLive pageBlocked by Trusted Types

None of the payloads ran. I repeated the last check on the live site after deploying it.

What's still missing

GitHub Pages doesn't let you set HTTP response headers. Some protections only work as headers, not as a <meta> tag: frame-ancestors against clickjacking, Strict-Transport-Security and Permissions-Policy. The risk is low because the site has nothing to click-trick anyone into, but it's still a gap.

The fix is to serve the site from somewhere I control the headers. That's the next issue.