notACMS 1.2.0 — what an audit turned up
php,symfony,static-site,open-source,security,ai1.2.0 started as a cleanup, turned into a full audit, and ended up being the most substantial notACMS release yet. Around 170 fixes across the core, both template trees, documentation, and JS — with a handful of deliberate breaking changes along the way.
A few months ago I wrote about running an AI audit on notACMS and finding 79 bugs. 1.2.0 is the follow-up: another full pass, but this time with the project in better shape and with more context in AGENTS.md to guide the review. The process found obvious things and non-obvious things. Here are the ones worth talking about.
The nginx bug that broke contact forms silently
The most frustrating find: a contact form that worked perfectly in every environment except Docker production.
The nginx config used a regex location to route the contact API:
location ~ ^/[a-z]{2}/api/contact$ {
Except it didn't. Nginx regex syntax treats {2} as a literal string in some contexts — the quantifier was being matched literally, not as "exactly two characters". The regex never matched, so POST /pl/api/contact never reached PHP. Every non-default locale got a silent 404 on form submission.
It worked fine in DDEV (which has its own nginx config) and in development (where PHP handles routing directly). Docker production, which used the committed nginx.conf.template, was broken from the start. One character fix: quote the braces.
locale-redirect: being helpful was harmful
The locale-redirect.js script reads the browser's language preference and redirects first-time visitors to their locale. Sensible idea, but the logic had a gap.
When someone lands on /pl/ for the first time with no lang cookie — say, following a link — the script read navigator.language, found en-US, set the cookie to en, and redirected them away from the page they'd explicitly navigated to.
The fix is obvious in retrospect: if the current URL is already on a non-default locale, the URL itself is the preference. Set the cookie to match and stop. No redirect. This also covers the HTTP dev case — the Secure cookie flag was silently dropping the cookie in non-HTTPS environments (DDEV's default), making the whole mechanism non-functional until you switched to HTTPS.
I also added a guard for a scenario I hadn't considered: a stale cookie for a locale that no longer exists on the site. Without it, removing a language from _site.yaml would redirect every visitor with an old cookie into an infinite redirect loop to a 404.
Search excerpts and double-escaping
The 1.1.2 security review found an XSS in search: pagefind excerpts were being inserted into innerHTML without escaping. Fixed by wrapping them in esc().
Except pagefind excerpts contain <mark> highlight tags — that's how pagefind shows what matched. The fix broke highlights, turning <mark>term</mark> into literal <mark>term</mark> text. The right answer: pagefind content is author-controlled static HTML, not user input. The <mark> tags are injected by the search engine at build time, not by visitors. Remove esc() from the excerpt specifically, keep it everywhere else. Invisible until you notice the highlights aren't highlighting.
Security and hardening
ImageMagick now runs through Symfony\Process with argv arrays instead of shell strings — exec() is gone. Turnstile validates the hostname in the siteverify response against your configured base_url, so tokens minted on a test or staging domain can't be replayed against production. JSON-LD output is hex-escaped, meaning </script> in a post title can no longer terminate the script block. The nginx security headers (X-Frame-Options, CSP, etc.) were missing from /assets/ and /media/ responses — location-level add_header was suppressing the inherited server-level headers.
/llms.txt
A small addition: a /llms.txt route included in the static build, listing the most recent posts in a machine-readable format per locale. Configurable via llms_limit in _site.yaml, overridable per-theme via the @base Twig namespace. Static sites aren't traditionally easy for LLMs to navigate — this is a low-effort way to provide context to whoever (or whatever) reads it.
Breaking changes
A few things changed in ways that require a one-line migration in local/src/:
ContentItem::directoryKey()returns the full content path (pages/aboutnotabout), fixing silent URL collisions between same-named directories in different sectionsgetTree()moved toContentTreeProviderInterface— offContentServiceInterfacestructured_data().blogPosting()takes a named map (was 13 positional arguments)lang_switch_urlcontext key removed — uselang_switchdocs/customization/old-template/removed from the repo — retrieve from a v1.1.x tag if needed
Full migration guide: UPGRADE-1.2.md.
Links
Full changelog: CHANGELOG.md.
Repository: GitHub / holas1337/notACMS — Apache 2.0.