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 |
| A clock that blocks the mandatory second factor | A board with no real-time clock, or one that is simply wrong, can make every code fail. Enrolling does not need console access to recover from that — see If the clock is wrong |
| 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 |
| Password floor | at least 12 characters, with a digit and a symbol — not configurable |
| Default password | none. The first-run wizard is mandatory |
| First run | only with the setup token easywall-web prints to its log at start — First Run |
| 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 | mandatory, per the single account — TOTP or a passkey, plus 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, not a
session. The second step is checked at /login/verify, in either order: a
typed code, a recovery code, or a passkey assertion. That step has no rate
limit of its own and does not need one. One intermediate state allows three
attempts against the code field or the passkey button — the two share one
counter — and a new intermediate state costs a password round. Five password
rounds are allowed per ten minutes per address, so fifteen attempts per ten
minutes per address, code and passkey combined, against a target that rotates
every thirty seconds.
That count is the server’s, held in memory against a random identifier the intermediate cookie carries. Until 2.18 it lived in the cookie itself, so the three bound a browser that kept sending back what the server handed it. A client replaying one frozen cookie went on guessing. Nothing is written back to that cookie now, so there is nothing for a client to decline to keep.
A restart empties the table, and a half-finished login then starts again with the full three. That direction is deliberate: forgetting costs an attacker one extra password round, and those are limited to five per ten minutes. Refusing instead would lock an operator out of their own firewall after a service restart.
Both halves are held by tests. Three attempts end the attempt whichever door they came through, and the sixteenth in ten minutes does not get through.
Passkeys are a second factor, never a replacement
A passkey is offered at /login/verify, after the password, exactly where the
code field already is — never instead of it, and never in place of the
password step. POST /login/passkey/begin and /finish both refuse without a
pendingLogin behind them, the same guard /login/verify itself opens with:
a stolen authenticator is not a login on its own. A challenge answers once:
the server remembers the ones it has spent, so a captured assertion resent with
its cookies is refused. Where an authenticator reports a signature counter that
is checked too, and one that did not advance is refused as
passkey_clone_suspected. Either is a failed attempt and never a lockout,
since TOTP, a recovery code and another passkey are all still there. Most
platform passkeys report no counter at all, so that second check is an extra
rather than the guarantee.
Mandatory since 2.18 means an upgrade with only a password meets the same gate
a fresh first run does. Every authenticated route redirects to /password
until a factor — TOTP or a passkey — is enrolled. Nothing about a passkey
being available changes that; TOTP alone still satisfies the gate.
A passkey can be unavailable, and the interface says which of three reasons is in the way rather than showing a control that fails silently in the browser:
| Reason | Why |
|---|---|
| The demo | Anyone can open it, and a passkey left there would stay |
No tls.hostname |
WebAuthn’s Relying Party ID must be a registrable domain; an installation reached by its bare IP address has none |
| A self-signed certificate | No browser trusts the pair easywall generates itself by default, and a ceremony begun anyway fails in the browser with a SecurityError the operator cannot act on |
The eight recovery codes are unaffected by all three, and are issued whichever factor came first. They are the way in when a passkey cannot be offered at all. TOTP is not: an account whose only factor is a passkey never had one.
If the clock is wrong
A code that will never verify does not have to end an enrolment attempt. The
most common cause is a board with no real-time clock, still at whatever it
booted to until NTP catches up. After one failed code, both the first-run
wizard and /password offer a second way through. An explicit
acknowledgement stores the secret already shown on screen and issues eight
recovery codes, and the account is usable immediately. Until the clock is
fixed, one of the eight codes is the way in; once it is, the authenticator
already paired works and normal sign-in works again.
This is gated on being the first factor. An operator who already has one is
not locked out by a failed code on /password and can simply leave the
page. The escape exists only for the account that cannot otherwise be used
at all.
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. That is 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 —
unless the peer sending the request is on trusted_proxies. trusted_proxies is
a list configured explicitly, never a boolean. easywall never believes
X-Real-IP, True-Client-IP or Forwarded. A client that can set its own
source address and is not on that list walks straight past the login rate limiter.
| What stays authoritative | r.RemoteAddr — the actual TCP peer, unless it is a listed proxy |
| The cost of an untrusted 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 | list the proxy in trusted_proxies — Behind a reverse proxy is the whole task, with nginx and Docker |
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+. Without a configured certificate easywall generates a
self-signed ECDSA P-256 one into ssl_dir. It replaces that certificate
once it comes within 30 days of expiry — checked 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 exception: ACME’s port 80
No other plaintext port is opened, but tls.acme = true opens one. A
certificate authority proves you control tls.hostname by connecting to port
80 over plain HTTP and reading back a token (HTTP-01). That is the inbound half.
The outbound half — what easywall sends the authority, and when — is a row in
Every request that goes out below.
easywall’s listener answers that one path and nothing else — 404 for
everything else. It is deliberately not a second web interface, and
deliberately not autocert’s own default, whose fallback redirects to
https://host/ where easywall is not listening.
It runs for as long as the service does, not only while a certificate is first being issued. Renewal happens on autocert’s own schedule, and a listener that only exists for the first issuance has silently stopped working by the time a renewal needs it.
Three things have to be true before that listener can answer at all, and easywall does none of them for you:
tls.hostnameresolves to this host from the public internet.- The service can bind port 80. The packaged systemd unit grants exactly
AmbientCapabilities=CAP_NET_BIND_SERVICEfor this; a unit built by hand needs the same line. -
Port 80 is open in your own easywall rules. easywall will not open a port by itself — a firewall that opens ports on its own initiative is not one you can reason about. So this is the one precondition on this list that is actually your job.
The System page reports whether it is, in your live rules, restricted or not. A rule for 80 whose
sourcesexcludes the public internet does not let the certificate authority in, even though the port is technically “in your rules”.The report does not see everything that decides reachability, though. A blocklist entry, a custom rule, or IPv6 mode =
blockcan each keep a certificate authority out even when the row says open. Let’s Encrypt prefers IPv6 when an AAAA record exists, and the report reads the ports table only.
A bind failure on port 80 is fatal to the whole interface, not only to ACME.
Start() refuses rather than run with a configuration it cannot honor — the
same choice this project already makes for a missing certificate file or
missing templates. If something else already holds port 80 — nginx in front,
another service — free it or turn tls.acme off. Nothing here retries in the
background or falls back to serving without it.
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
Five, 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 |
| Notifications | an address you choose | when one of the four things you ticked happens, and whenever you press Send a test | the event, its detail, this host’s name and the version | off until you set an address |
| Feeds | each list you switch on, at the address the catalogue names — or an address you choose for an own feed | on the list’s schedule, hourly to daily | nothing about you — a GET with the version in the User-Agent; to an own feed, the user and password you gave it | off, each one, until you switch it on |
| A certificate | the ACME directory, Let’s Encrypt unless acme_directory names another |
on first need, and again before expiry | the one name in tls.hostname, your agreement to the authority’s subscriber terms, the public half of an account key made here, and acme_email if you set one |
off until acme = true |
The first four are not on the path of a page. On a host with no route out they simply fail, and nothing else changes. The exact request the count makes is printed verbatim under Configuration.
The certificate is not one of those. It is on the path of every page, because
it is what serves them. A host that cannot reach the authority goes on answering
while the certificate cached in <ssl_dir>/acme is still valid — weeks, normally.
It stops when there is nothing valid left to serve, and the first issuance is the
unforgiving one: there is no cache yet. The inbound half of that exchange, and the port 80 bind that is
fatal when it fails, are above.
The private half of the account key never leaves the host. What goes out is its public half, signing each request.
The notification and an own feed are the two easywall cannot name at all.
api.github.com,telemetry.wdkro.deand the catalogue’s lists are fixed, and the certificate authority is a default you may replace. Where a notification goes is yours from the start, so nothing here can promise where it lands. Only that it goes nowhere until you set an address, that redirects are refused, and that the address is a credential. See Notifications. An own feed is held tohttps://, orhttp://to this host only; redirects are refused there too, and its password is never shown back or logged. It is kept in plain text in<data_dir>/web/feed_fetch.json, readable by the web user alone, because the web process has to send it.
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. Thirteen events: signed in, sign-in failed, second factor failed, a recovery code used, sign-in attempts blocked, and signed out. A factor switched on, off or regenerated, a passkey used, enrolled or removed, and a passkey whose counter did not advance. See the thirteen login events. None of them carries colour: a sign-in does not move the firewall.
Reading it: Audit log.
Refused packets
The Blocked page holds the source and destination address of every packet the enabled log switches refuse. That is personal data, so here is exactly where it lives:
| Memory | easywall-core only, the last entries packets (20 000 by default), lost on restart |
| Disk | nothing, by default. With persist = true: /var/log/easywall/packets.log, 0600 root — it rewrites itself to the ring, oldest first out, once it holds more than twice entries lines, and needs no logrotate |
| Leaves the host | never. The web process asks the core over the socket; nothing is sent anywhere |
| Erase it | systemctl stop easywall-core && rm /var/log/easywall/packets.log — also the repair for a corrupt file: a line over 64 KiB stops the replay and the log falls back to memory-only until the file is removed and the core restarts |
Retention is bounded by count, not time: at the default sixty lines a minute, 20 000 entries is about five and a half hours, and a flood shortens it.
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. Research is welcome and unpaid; with your consent, a responsible report is credited in the advisory and the release notes. Test on your own installation, never on someone else’s.