← Blog

Knowing what your nginx config actually allows — an auditor that computes effective access

DEENESRUUK

An nginx config rarely tells you, at a glance, who can actually reach what. allow/deny directives inherit downward, location blocks shadow one another, an auth_basic two levels up may or may not apply, and a single missing default deny quietly leaves a door open. nginx-acl-guard answers one question deterministically: for every server/location, what is the effective access policy — and did the change I'm about to make just widen it?

Compute the policy, don't read it

The audit never greps the config. It parses with crossplane into the real directive tree, resolves inheritance the way nginx actually does, and emits the effective allow/deny for each endpoint. On top of that sit named checks — accidental exposures (A1..A7), TLS weaknesses (T1..T6), risky upstream patterns (U1..U4) — so "this location is reachable from the public internet" surfaces as a finding, not as something you discover during an incident.

One of the project's golden rules is blunt about how this is allowed to work:

Deterministic parsing only — crossplane, no regexes, and no LLM making security decisions.

A regex that is 99% right about an access rule is a vulnerability. Security posture has to be computed, reproducibly, or it can't be trusted.

Guard the change, not just the audit

Auditing is the easy half. The interesting half is letting someone edit access safely. ACL profiles can be imported from existing snippets or generated from scratch, assigned to sites, and combined as union-allow + default-deny. But the live config on disk is sacred — nothing writes to it directly. Every apply goes through one pipeline:

edit / assign an ACL profile
        │
        ├─ git backup of the current config
        ├─ render into a staging tree
        ├─ nginx -t on staging          → is it even valid?
        ├─ diff the EFFECTIVE policy     → what access actually changed?
        ├─ access expanded? ───────────→ require an explicit confirm token
        ├─ atomic swap (symlink flip)
        └─ rollback on any error

The diff is the whole point. It is not a text diff of the config — it is a diff of the computed effective policy. Tightening access applies quietly; expanding it — a new allow, a dropped deny, a route that becomes public — refuses to proceed without an explicit confirm token. You can't widen access by accident, because widening is detected by construction rather than caught in review.

Least privilege, all the way down

The service runs as an unprivileged nginxguard user, read-only by default, writing only to its own profiles directory. The few privileged steps — testing the staging tree, the atomic swap — go through a tiny helper binary with a narrow contract, never the web process itself. On any error the rule is a hard stop: no automatic workaround, no half-applied "best effort" state to untangle later. And generating server blocks from scratch is deliberately out of scope — the tool guards access to sites, it doesn't invent them.

The stack is small and boring on purpose: Python, FastAPI, Jinja2 + HTMX, raw SQLite with versioned migrations, crossplane for parsing. The value was never in the framework. It's in treating "what does this config allow" as something you compute and diff — the same way you'd treat a number — instead of something you eyeball and hope you read correctly.

Transparency: most posts here are drafted with AI assistance, and the non-English versions are machine-translated by a local LLM and then reviewed. Spotted an error? Please let me know.