Security Model
The whole design follows from one decision: the process reachable from the network holds no privilege worth stealing.
Threat model
| Threat | Mitigation |
|---|---|
| Rule or command injection | Every typed rule goes to the kernel as a Go struct over netlink — no shell, no argv, nothing to escape. Custom rules are the one exception and are handled below |
| Escalation from the web process | It has no kernel access. Reaching nftables needs a command the typed protocol accepts |
| Auth brute force | Argon2id, plus 5 attempts per 10 minutes per source address |
| CSRF | Go 1.25 net/http.CrossOriginProtection — Origin and Sec-Fetch-Site on every unsafe method |
| XSS | html/template escapes by default; CSP with no 'unsafe-inline' and no external origin |
| Session hijacking | HTTPS only, HttpOnly, Secure, SameSite=Lax, 600-second lifetime, and every session ends the moment the password changes |
| Locking the admin out | The acceptance window rolls back on its own; if it already has and you are still shut out, easywall-core panic reaches the firewall from the console — see Panic mode |
| Known CVEs in dependencies | govulncheck on every pull request and weekly, plus CodeQL and gosec |
| Dependency hijacking | Renovate raises every update, patch releases auto-merge only once CI is green, minor and major wait for a person; plus secret scanning and dependency review |
| A spoofed source address | X-Forwarded-For is not trusted — see behind a reverse proxy |
Authentication
| Hash | Argon2id — 64 MiB, 3 iterations, parallelism 4, 16-byte salt per password |
| Default password | none. The first-run wizard is mandatory |
| Rate limit | 5 attempts, refilling one every 2 minutes, per source address |
| Session | 600 s · HttpOnly · Secure · SameSite=Lax |
| Cookie signing key | generated on first start unless configured. Anyone holding it can forge a session, and the placeholder in the sample config is published here — so a missing, short or placeholder key is replaced and written back |
| Logout | ends that session immediately, and only that one. The identifier is recorded as revoked, because a signed cookie is self-contained and telling the browser to drop it leaves the value working. The record is in memory: a restart within ten minutes forgets it |
| Password change | ends every other session at once. Each carries a fingerprint of the password hash it was issued under and is refused once that stops matching |
| Recovery | none by design — no mail, no outside service. Clear the password line on the host |
| Second factor | optional, per the single account — TOTP and eight recovery codes. Enabling or disabling one ends every other session, the same way a password change does |
With a second factor enrolled, the password step ends in a redirect rather than a
session, and the code is checked at /login/verify. That step has no rate limit
of its own and does not need one: three code attempts per intermediate state,
five password rounds per ten minutes per address, so fifteen code attempts per
ten minutes per address against a target that rotates every thirty seconds.
TestLoginVerify_TheSixteenthCodeAttemptDoesNotGetThrough is that sentence as an
executable claim.
Panic mode
Since 2.7, easywall-core puts the last confirmed rule set back into the kernel
at startup — a reboot no longer empties the firewall the way it used to, and no
longer works as an accidental way back in. easywall-core panic is the
deliberate one that replaces it: a console command that takes the firewall down
immediately, whether or not the web interface is reachable at all.
It is a new way to disable the firewall, in full, from the console — and that belongs on this page whether or not it feels like a feature. Two things about it matter for the rest of this model:
- It survives a restart on purpose. Panic mode is recorded in a marker file and the startup restore refuses to run while it exists — otherwise the next reboot would put the very rules back that panic mode exists to remove.
- While the marker exists, an apply is refused and the acceptance rollback
stops at the kernel. The stored rules are still reverted — an apply nobody
confirmed never gets to keep
Current, or the next restore would install it with no window of its own — but the previous rules are not written back into a table the console has deliberately torn down. That kernel half is the one place in easywall where the central promise, every apply reverts itself unless you confirm it, is switched off outright, because there is nothing running to roll back onto. Both take effect the instant the marker is written and end the instanteasywall-core resumeclears it.
Ending panic mode is console-only, without exception: the banner the interface shows carries no button. A control there would let the process reachable from the network re-arm a firewall a human just disarmed at the machine — on purpose, possibly using a stolen session. Full detail, real output, and the marker’s path: Recovery & Panic Mode.
Behind a reverse proxy
easywall terminates TLS itself and does not believe X-Forwarded-For,
X-Real-IP or True-Client-IP. It is not assumed to sit behind a trusted proxy,
and a client that can set its own source address walks straight past the login
rate limiter.
| What stays authoritative | r.RemoteAddr — the actual TCP peer |
| The cost behind a proxy | every request looks like it comes from the proxy, so the limit of five attempts per ten minutes is shared by everyone. One person getting it wrong repeatedly locks the rest out until it refills |
| What to do about it | reach easywall directly, or on a private network. A configurable list of trusted proxies is on the roadmap — a list of addresses, never a boolean |
This only affects the login limiter. The firewall’s own protection modules count per source address in the kernel and are unaffected by any HTTP header.
Transport
HTTPS only, TLS 1.2+. No plaintext port is opened at all. Without a configured
certificate easywall generates a self-signed ECDSA P-256 one into ssl_dir, and
replaces it once it comes within 30 days of expiry — at startup, and twice a day while
the service is running. The certificate is read per handshake rather than once at
startup, so a renewal takes effect without a restart. That matters for a service that
may well outlive its own one-year certificate.
A certificate you configure yourself is never overwritten. It is re-read when the file changes, so an ACME client renewing it in place needs no restart either.
[tls]
cert = "/etc/letsencrypt/live/example.com/fullchain.pem"
key = "/etc/letsencrypt/live/example.com/privkey.pem"
The one place a string reaches a command
Every typed rule reaches the kernel as a Go struct over netlink. Custom rules are
the exception: the netlink library takes typed expressions rather than text, so a
statement you write by hand is applied by putting
add rule inet easywall input <your rule> into nft -f -. Saying “no subprocess
in the apply path”, as this page once did, was not true.
| The risk | nft reads a newline and a semicolon as the end of one command and the start of the next, so a rule carrying either is a second command run by the root daemon |
| Demonstrated | not theoretical — an imported rule containing a newline wrote into a neighbouring table on a real kernel |
| The mitigation | both characters refused on save, on import, and again inside the core, by the same check |
| Why on the shape | a check that parsed nftables would depend on nft’s grammar, on a subprocess being available, and on the syntax-check wrapper happening to be balanced. This one depends on none of them |
| Also bounded | 256 statements per check |
What remains is what the feature is for: an operator with an account can write firewall rules, which is also true of every other page.
Nothing is loaded from a third party
Fonts, stylesheet, icons and htmx are served by easywall itself, and the policy permits no external origin:
default-src 'self'; script-src 'self' 'nonce-<per-request>'; style-src 'self';
font-src 'self'; img-src 'self' data:; connect-src 'self'
Two reasons, both practical. An administrative interface should not report a visit to anyone — the earlier build loaded its typefaces from Google Fonts, which did exactly that. And easywall often runs on hosts with no outbound route, where that request simply failed and left the typography broken on the machines the tool is built for.
A constraint on contributions.
style-srchas no'unsafe-inline', so assigningelement.style.*from JavaScript, or letting a library inject a<style>block, is blocked. Scripts toggle a class instead.
Every request that goes out
Two, and this is the whole list.
| Destination | When | Carries | Default | |
|---|---|---|---|---|
| Update check | api.github.com |
once a day | nothing about you — a plain GET for the newest release | on, update_check = false removes it |
| Installation count | telemetry.wdkro.de |
once a day | a random identifier generated on your machine, and the version | off until you switch it on |
Neither is on the path of a page, and on a host with no route out both simply fail and nothing else changes. The exact request the count makes is printed verbatim under Configuration.
Fixed in v2.4.0. htmx was configured through a listener for an
htmx:configevent, which htmx does not emit — soallowEvalstayed at its default oftrueand the script nonce was never applied. It goes through themeta[name=htmx-config]tag now. Found by tighteningstyle-src, which surfaced an inline<style>block htmx had been injecting unnoticed.
What the audit log actually records
One JSON object per line in <log_dir>/audit.log, rotated daily, 30 days kept:
{"time":"2026-08-04T14:25:13Z","action":"apply_started","rule_type":"all","detail":"","user":"web"}
{"time":"2026-08-04T14:25:43Z","action":"apply_accepted","rule_type":"all","detail":"","user":"web"}
{"time":"2026-08-04T14:30:00Z","action":"apply_rolledback","rule_type":"all","detail":"timeout","user":"web"}
user is always web, whichever account signed in: the socket protocol carries
no identity yet. It names the process, not the person — see the
roadmap.
| Recorded | Not recorded |
|---|---|
apply_started · apply_accepted · apply_rolledback · apply_failed |
the source address of a change |
rollback_failed — new rules did not take and the old ones did not return |
which account made the change |
rules_saved · rules_imported |
|
options_saved · settings_saved · system_saved |
Since 2.8, logins are in the audit log. Nine events — signed in, sign-in failed, second factor failed, a recovery code used, sign-in attempts blocked, signed out, and the second factor switched on, off or regenerated — see the nine login events. None of them carries colour: a sign-in does not move the firewall.
Reading it: Audit log.
The CVE that shaped this
easywall v0.3.1 — Python, Flask, iptables — was archived in 2022 after a
disclosure. Four root causes, and what replaced each:
| v1 | v2 |
|---|---|
| Web process ran as root — one injection was full compromise | Web runs unprivileged; there is no kernel access to misuse |
iptables through subprocess with user-controlled strings |
google/nftables over netlink — no shell, no argv |
| File-based IPC with sentinel files — racy | A typed protocol over a Unix socket |
| SHA-512 salted with the hostname — trivially reversed | Argon2id with a random 16-byte salt per password |
What this does not protect you from
| A compromised root account | root owns the core |
| A kernel nftables vulnerability | that is below easywall entirely |
| An administrator writing a bad rule | the audit log records it; nothing prevents it |
Anyone holding session_key |
it signs the cookies, so it is a login. Keep it out of backups you share |
Reporting a vulnerability
Not as a public issue. Use GitHub Security Advisories for private disclosure — see SECURITY.md.