Security by Design
Warden treats security as a foundational property, not a checklist of features. Filtering is one layer of many, alongside bypass-resistance and host hardening. The threat model is published in full, the controls are named, and the elements explicitly out of scope are also listed.
Defense in depth
Filtering is one layer. Amplification, tunneling, supply-chain, IP spoofing, brute-force, rebinding, open-resolver — fourteen named threats, each with an independent control.
Warden’s threat model is published in full, with both the in-scope threats and the deliberate omissions. Each named class of risk has a dedicated control rather than a single perimeter that has to do everything.
- Rate limiting (RRL), RFC 8482 ANY-refusal, tunneling heuristics, MAC enforcement, a
flock-based PID-file singleton — each addresses one named class of risk. - IP-level blocking goes one layer deeper than the domain. With
[ip_blocklists]enabled, every A/AAAA in upstream answers is checked against IP feeds. Fast-flux botnets and hijacked CDN hosts caught even when the domain itself is clean. - A per-client token-bucket rate limit catches abuse on your own LAN. RRL defends against spoofed amplification from outside; the per-client rate limit (
queries_per_second100/s, burst 200) catches a chatty Smart TV or a misbehaving app from the inside. - An Operator Configuration Contract lists the eight settings you must turn on to claim the full posture. Without those switches, you have a resolver that filters; with them, you have one that filters and resists active abuse.
- Out-of-scope items (ARP spoofing, IDN homoglyphs, host compromise) are listed too, so the trade-offs are visible. The daemon does not pretend to defend the network from a compromised host on the same LAN.
Devices can’t escape to a public resolver
Smart TVs, mobile apps, anything that hardcodes 8.8.8.8 or talks DoH/DoT to a public resolver — caught at the name level, blocked at the IP level.
A device that hardcodes its own DNS makes the operator’s filter optional. Warden closes both layers of that escape: it blocks resolution of public DoH/DoT resolver hostnames so a device can’t even discover the bypass endpoint, and it ships a one-command generator for the firewall rules that close the IP-level holes.
[anti_bypass]ships with built-in coverage of 40+ public resolver domains — Google DNS, Cloudflare, Quad9, NextDNS among them — plus operatorextra_domains.warden firewall-rulesprints ready-to-paste nftables/iptables snippets that redirect every outbound port 53 through the daemon and reject ports 443 and 853 to a built-in list of public DoH/DoT resolvers.
External lists are sandboxed
Subscribed lists can only contribute block rules. Allow overrides, priority elevation, and regex are reserved for the operator’s own rules.
Pick a community blocklist. One day it might be compromised, or simply maintained by someone whose judgement diverges from yours. In Warden, the blast radius is bounded: the upstream can add domains to the blocklist, and that is all.
@@(allow-overrides),$important(priority elevation), and regex rules are stripped from external lists at parse time — Warden’s external-list parser (src/lists/parser.rs) is sandboxed. They only take effect when you write them in your ownconfig.toml.- The list downloader is SSRF-hardened: HTTPS-only on the initial URL and on every redirect, private/CGNAT/loopback IPs rejected, body capped at
lists.max_body_bytes. - This is the discriminator versus other DNS filters, which do not enforce this split. A compromised upstream list there can silently unblock a malicious domain via
@@; here, it cannot.
Safe by default
Misconfiguration that would expose the network refuses to start. Unsafe rules don’t even get the chance to be loaded.
The shape of the config you can write is constrained. The most dangerous mistakes are caught at config-load time, and many are caught earlier still, at the moment you try to write the rule. The expensive failures in DNS aren’t bugs; they’re correctly-running daemons sitting on misconfiguration nobody noticed. Closing those at boot, or at write time, is cheaper than closing them after.
- If Warden listens on every network interface (
0.0.0.0or[::]) but you haven’t said which clients may use it (server.allow_fromleft empty), anyone on the internet could aim their queries at your resolver and use it to attack others — the classic “open resolver” mistake. Warden refuses to start until you name the allowed range. - Strict allowlist by default. Warden resolves each client through a five-step chain — device, schedule, group, subnet, then a global default. A source IP matched by none of
[[devices]],[[groups]], or[[subnets]], withserver.default_profileleft unset, reaches that last step with no profile and is refused. TheRCODE=REFUSEDis hardcoded in the resolver and ignores any profile’sblock_response; nothing permissive can hide here. (The legacyserver.block_unmapped_clientsflag was retired in SN3 — the same effect is now structural: leavedefault_profileunset.) - A
secrets.tomlmode wider than0600is a hard refuse-to-boot, with the exactchmodcommand printed in the error. - The CLI validator refuses unsafe rules at write time, not just at boot — Cyrillic-homoglyph domains (
gооgle.com≠google.com), reserved-IP targets in local DNS, public-suffix wildcards (*.it,*.co.uk), and per-device allow rules that would override an explicit profile-level deny. Each refusal carries a frozen English error message; nothing slides in silently.
Append-only audit log
Every boot, shutdown, reload, and CLI mutation is written to disk, append-only, with the actor’s identity. Lifecycle events also carry a hash of the config tree before and after.
The audit log is the answer to “what changed, who did it, when”. It lives at /var/lib/purge-warden/audit/audit.log, one JSON line per event, append-only. For daemon-side events the invoker’s UID is read from the kernel via SO_PEERCRED — the daemon trusts the kernel’s word, not the caller’s; a CLI mutation records the UID of the warden process that made the change.
- Lifecycle events (boot, shutdown, reload, restore) plus per-rule CLI mutations:
action,scope,target_id,domain,rule_id,override_used. Lifecycle entries (not per-rule CLI mutations) also carry SHA-256 hashes of the config tree before and after. - File mode
0640, grouppurge-warden— log-shipping daemons can read it without root, the rest of the system cannot. - Read it from your shell without elevated privileges:
warden audit tail -n 20.
Daemon runs sandboxed, not as root
Even if the resolver is compromised, the blast radius stops at the daemon’s own filesystem and capability set — and it never runs as root.
Warden ships with a systemd unit (systemd/purge-warden.service) that uses 25+ hardening directives. The daemon binds port 53 via AmbientCapabilities=CAP_NET_BIND_SERVICE without ever being root, runs as a dedicated non-root purge-warden system user, and sees a minimal slice of the host filesystem.
ProtectSystem=strict,PrivateTmp,NoNewPrivileges,SystemCallFilter— the standard systemd hardening set, applied.MemoryDenyWriteExecute,LockPersonality,RestrictNamespaces,RemoveIPC— the daemon can’t map writable-executable memory, spin up namespaces, or touch SysV IPC.- The unit ships at
systemd/purge-warden.serviceand is wired non-interactively by the installer.