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:ordata: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.
| Payload | Where | Result |
|---|---|---|
<img src=x onerror=...> | Write-up title | Shown as text |
<script>...</script> | Write-up tag | Shown as text |
"><svg onload=...> | Write-up summary | Shown as text |
javascript:... | Write-up link and CV link | Link removed |
data:text/html,... | Write-up link | Link removed |
Inline <script> in the HTML | Page source | Blocked by CSP |
eval() | Site script | Blocked by CSP |
innerHTML assignment | Live page | Blocked 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.