Problem
Running a security scan against a static site hosted on Namecheap returned six missing headers:
- `Strict-Transport-Security` — not set
- `Content-Security-Policy` — not set
- `X-Frame-Options` — not set
- `X-Content-Type-Options` — not set
- `Referrer-Policy` — not set
- `Permissions-Policy` — not set
Missing security headers aren't theoretical risks on a static blog — but they are a fast fail on any professional security review, and the CSP gap in particular matters because without it, any injected script runs freely in the user's browser.
There was a second problem underneath the header issue: the site had inline blocks in every HTML file. A strict Content Security Policy blocks inline scripts by default, so fixing the headers meant first eliminating the inline scripts.
Root Cause
The site was built with all JavaScript either inline in each HTML file or in a script.js that existed on disk but was never actually linked anywhere. The inline scripts handled three things: setting the footer year, wiring up the mobile nav toggle, and running the blog filter. The script.js was dead code.
Without moving that logic out of inline scripts, any CSP would need 'unsafe-inline' — which defeats the entire point of having a CSP.
Fix
Step 1 — Consolidate JavaScript into script.js
Moved all inline script logic into script.js with null guards so the same file runs safely on every page type:
document.getElementById('year').textContent = new Date().getFullYear();
const navToggle = document.querySelector('.nav-toggle');
if (navToggle) {
navToggle.addEventListener('click', function() {
document.querySelector('nav ul').classList.toggle('open');
});
}
const filterButtons = document.querySelectorAll('.filter-btn');
if (filterButtons.length) {
const posts = document.querySelectorAll('.post-card');
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filter = btn.dataset.filter;
posts.forEach(post => {
post.style.display = (filter === 'all' || post.dataset.tag === filter) ? 'flex' : 'none';
});
});
});
}
Step 2 — Replace inline scripts in all HTML files
Replaced the inline block in index.html, blog.html, and all 12 post HTML files with a single (or ../script.js for posts in a subdirectory). Also fixed the create_post.py template so new posts generate clean.
Step 3 — Add security headers to .htaccess
Namecheap shared hosting runs Apache. Security headers go in .htaccess inside so the block only activates if mod_headers is loaded (it is, on Namecheap):
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()"
What Each Header Does
Strict-Transport-Security — Tells browsers to always use HTTPS for this domain, even if the user types http://. The preload flag registers the domain in browser preload lists so first-time visitors also get HTTPS. max-age=31536000 is one year.
Content-Security-Policy — Defines exactly where the page can load resources from. default-src 'self' means only this domain. No CDNs, no inline scripts, no external fonts. If an attacker injects a tag pointing to their server, the browser blocks it.
X-Frame-Options — Prevents the page from being embedded in an on another site. Stops clickjacking attacks where a malicious site overlays your page inside a transparent frame. frame-ancestors 'none' in the CSP does the same thing for modern browsers — setting both gives coverage for older browsers that don't support CSP.
X-Content-Type-Options — Tells the browser not to guess the content type of a response. Without this, a browser might interpret a text file as executable JavaScript if it looks like code. nosniff disables that guessing.
Referrer-Policy — Controls what URL gets sent in the Referer header when a user clicks a link off your site. strict-origin-when-cross-origin sends only the domain (not the full path) for cross-origin requests, and nothing for downgrade to HTTP. Prevents leaking full URLs to third-party sites.
Permissions-Policy — Disables browser features the site doesn't use. Prevents third-party scripts (if any were ever added) from accessing camera, microphone, geolocation, or payment APIs without explicit permission.
Gotcha — HSTS is a One-Way Door
Strict-Transport-Security with preload tells browsers to enforce HTTPS permanently. Once deployed and cached by browsers, there's no quick way to revert to HTTP. Verify SSL is working correctly on the domain before adding this header. If you need to back it out later, serve max-age=0 first and wait for the TTL to expire across clients before removing HTTPS.
Exact Pattern
1. Check for inline scripts in all HTML — they must be moved to an external file before a strict CSP will work
2. Wrap headers in on shared Apache hosting
3. Use Header always set not Header set — the always keyword applies the header even on error responses (4xx, 5xx), which matters for CSP
4. Verify with browser DevTools → Network → response headers after deploy