XSS, Open Redirects, and Path Traversal on a 'Static' Site
security,static-site,php,nginxPart 3 of 5
- 368 Tests for a Static Site Generator — Why Bother?
- 79 Bugs in a Symfony Codebase That Passed PHPStan
- XSS, Open Redirects, and Path Traversal on a 'Static' Site
- The Local Override Pattern — Symfony Templates Without Forking
- Open-Sourcing notACMS — The Complete Checklist
This is part 3 of a series on preparing notACMS for open-source release. Part 2 covers the AI code review. The original WordPress to Symfony series covers the migration itself.
"Static sites are secure." It's the most common thing people say when you tell them you replaced WordPress with static HTML. And it's true — as long as "static" means "no database and no user accounts." The moment a static site has a contact form, a search function, or any JavaScript that renders user content, the attack surface isn't zero. It's just different.
Here are the vulnerabilities I found in mine.
XSS in search.js — and why the obvious fix was wrong
Pagefind returns search results as JSON. The JavaScript renders them with innerHTML. The initial instinct after a code review was to escape the excerpt:
// "Fixed" in 1.1.2
'<p class="post-card-excerpt">' + esc(r.excerpt) + '</p>'
It passed review. Shipped. Then someone searched for something and noticed the highlighted match wasn't bold anymore — it showed as literal text:
…results for <mark>notacms</mark> in…
Pagefind injects <mark> tags into excerpts at query time to highlight matched terms. esc() escaped those tags along with everything else. The "security fix" silently broke search highlighting.
The revert:
// Current
'<p class="post-card-excerpt">' + r.excerpt + '</p>'
This isn't leaving a vulnerability in place. r.excerpt isn't user input — it's generated by pagefind from your own pre-indexed static HTML. The only HTML it contains is the <mark> tags pagefind itself injects. The real rule isn't "always escape before innerHTML" — it's know who controls the string. Every other field (r.url, r.meta.title, r.meta.category, tags) is escaped, because those come from frontmatter that could in theory contain anything.
The naive escape was worse than no fix: it introduced a regression, and gave a false sense that the XSS surface had been addressed.
Open Redirect via Referer Header
The draft and scheduled preview controllers toggle visibility and redirect back:
// Vulnerable (pre-1.1.x)
return $this->redirect($request->headers->get('referer', '/'));
The Referer header is user-controlled. An attacker can craft a link that takes a user through the preview toggle and then redirects them to an arbitrary external site. The 1.1.x fix validated that the redirect target is a local path:
// Fixed in 1.1.x
$referer = $request->headers->get('referer', '/');
if ($referer && str_starts_with($referer, '/') && !str_starts_with($referer, '//')) {
return $this->redirect($referer);
}
return $this->redirect('/');
1.2.0 tightened this further. The str_starts_with check operates on the raw header string — defensible, but fragile against malformed or unusual inputs. The improved version uses parse_url() to extract host and path separately, then validates each component explicitly, and redirects to the path only (never the full header value):
// Improved in 1.2.0
$referer = (string) $request->headers->get('referer', '');
$refererParts = parse_url($referer);
$refererHost = is_array($refererParts) ? ($refererParts['host'] ?? null) : null;
$refererPath = is_array($refererParts) ? ($refererParts['path'] ?? '') : '';
if (
(null === $refererHost || $request->getHost() === $refererHost)
&& str_starts_with($refererPath, '/')
&& !str_starts_with($refererPath, '//')
) {
return $this->redirect($refererPath);
}
return $this->redirect('/');
Path Traversal in MediaController
The variant cache serves resized images. The variant filename comes from the URL:
$variantPath = $this->cacheDir . '/' . $variantFilename;
Without a realpath() guard, a crafted URL like ../../etc/passwd could escape the cache directory. The fix is a single check:
$realCacheDir = realpath($this->cacheDir);
if (false === $realCacheDir || !str_starts_with(dirname($variantPath), $realCacheDir)) {
throw new NotFoundHttpException('Invalid variant path');
}
CSRF on Pre-rendered Contact Forms
This one is architectural, not a bug. Symfony's CSRF tokens are tied to the user's session. But static pages don't have sessions — the HTML is generated once at build time. Every visitor would get the same token, baked into the static file, which can't validate against their own session.
The real defenses are different:
- Turnstile CAPTCHA — replaces the bot-prevention layer with server-side verification
X-Requested-With: XMLHttpRequest— blocks simple form submissions from non-JS clients- nginx restriction — PHP-FPM only reachable at
^/(api|pl/api)/
Cookie Secure Flag — the flag that breaks dev silently
The locale redirect sets a lang cookie client-side. ; Secure was in the code from day one:
document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax; Secure';
Then during development on DDEV over HTTP, the locale redirect stopped working. Clicking the language switcher did nothing. The cookie just wasn't there.
; Secure tells the browser to only set the cookie on HTTPS origins. On HTTP it silently does nothing — no error, no warning, no console message. The locale mechanism was completely non-functional on any HTTP origin.
The fix is to make it conditional:
var secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax' + secure;
Production is always HTTPS, so ; Secure is always added there. Dev over HTTP gets a working cookie without it. The cookie banner uses unconditional ; Secure — consent doesn't need to persist on HTTP dev, so that one is fine as-is.
The lesson isn't "remember to add ; Secure." It's that security flags which silently fail are a class of their own. You add them, feel good, and don't find out they broke something until you're deep into debugging an unrelated problem.
What "Static" Actually Protects You From
SQL injection — no database. Server-side template injection — templates are compiled at build time. File upload attacks — no uploads. Brute-force login attacks — no login page.
What it doesn't protect you from: client-side XSS, open redirects, path traversal in dynamic endpoints, CSRF on forms, missing security headers, JavaScript that trusts untrusted data.
The Contact Form: The Only Dynamic Endpoint Is the Most Attacked
nginx restricts PHP-FPM to a single pattern:
location ~ ^/(api|pl/api)/ {
fastcgi_pass $php_upstream;
}
Everything else is served from disk. The contact form has Turnstile verification, a tight CSP that only permits challenges.cloudflare.com as an external domain, and no 'unsafe-inline' or 'unsafe-eval' in script-src:
add_header Content-Security-Policy
"default-src 'self';
script-src 'self' challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
frame-src challenges.cloudflare.com;
connect-src 'self' challenges.cloudflare.com;"
always;
The 'unsafe-inline' in style-src is a deliberate compromise: Turnstile's widget injects inline styles that can't be avoided without a CSP nonce. Everything else is locked down.
The next post covers the local override pattern — how notACMS lets users customise templates, CSS, and translations without forking.