goethite
GitHub

Security settings

On this page

goethite is built to be safe by default: the protections on this page are on unless you turn them off. The threat model lists every threat and what defends against it.

In a DNS rebinding attack, a web page from a name the attacker controls makes that name resolve to an address inside your network, such as your router's 192.168.1.1. The browser still thinks it is talking to the attacker's site, so the page can reach devices that were never meant to be exposed to the internet.

goethite removes such answers: when a forwarded answer for a public name contains an A or AAAA record with a private address, that record is dropped before the answer is cached or sent, and the ipv4hint and ipv6hint parameters of an HTTPS or SVCB record (RFC 9460) lose their private addresses too, since a client may connect to a hint before the A and AAAA records arrive. These addresses count as private:

Range What it is
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 private IPv4 networks (RFC 1918)
100.64.0.0/10 carrier-grade NAT (RFC 6598)
127.0.0.0/8, ::1 loopback
169.254.0.0/16, fe80::/10 link-local
0.0.0.0/8, :: "this host" and unspecified
fc00::/7 unique local IPv6 (RFC 4193)
64:ff9b:1::/48 local-use NAT64 (RFC 8215)

IPv4 addresses mapped into IPv6 (::ffff:192.168.1.1) are checked as IPv4. The question name decides, not the names in the answer: a public name that is a CNAME for printer.lan still loses its private addresses.

Names below the private domains may resolve to private addresses, so a local DNS server behind goethite keeps working for them:

[security]
rebinding_protection = true
private_domains = ["lan", "home.arpa", "internal", "local"]   # the defaults

Add your own domains to private_domains if a public domain you own resolves to internal addresses (split-horizon DNS), for example private_domains = ["lan", "home.arpa", "corp.example"]. Setting private_domains replaces the defaults, so list them again if you still want them. Answers goethite gives itself, such as blocked names answered with 0.0.0.0, are never touched.

A new node answers only the clients a local network can hold: loopback, the RFC 1918 ranges, link-local, carrier-grade NAT and the IPv6 unique-local and link-local ranges. It is not an open resolver on a public host unless you widen the list. When you edit the list, yours applies, and an empty allowed list means every client is answered. In the web UI's Settings, or through the API (access in /api/v1/settings), two lists take addresses, networks in CIDR notation and client IDs:

  • Allowed clients: when not empty, only these are answered.
  • Blocked clients: never answered, even when allowed.

The defaults are 127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, fe80::/10, fc00::/7 and 100.64.0.0/10.

Both apply: with 192.168.1.0/24 allowed and 192.168.1.66 blocked, every device on the network but one is answered. The lists are part of the replicated configuration, so in a cluster they are the same on both nodes, and they take effect at once, without a restart.

"access": {
  "allowed": ["192.168.1.0/24", "fd00::/64", "anna-phone"],
  "blocked": ["192.168.1.66", "guest"]
}

A refused client learns little and costs little. Its UDP queries get no answer at all, so a forged source address gets nothing either. Its TCP, DNS over TLS, HTTPS and QUIC connections are closed on accept, before a TLS handshake. Over DNS over TLS, HTTPS and QUIC a client may still be allowed by its client ID, which arrives with the handshake; if the ID turns out not to be allowed, or to be blocked, each query gets REFUSED and shows in the query log as rejected. Oblivious DoH queries are checked by the address of the proxy that relays them.

Loopback addresses always pass, so this machine's own tools and the floating IP's health check keep working whatever the lists say; only a blocked client ID is refused even there. The lists do not cover the API and the web UI, which have their own token. Each list holds at most 10,000 entries. goethite_access_refused_total{protocol=...} counts what the lists refuse.

A resolver that answers anyone can be abused to flood a third party: an attacker sends small queries with the victim's address as the source, and the resolver sends the larger answers to the victim. goethite limits how many queries each client network may send, before resolving them, so a flood also never reaches the cache or the upstreams. One limit counts a client's queries over UDP, TCP, DNS over TLS, HTTPS and QUIC together:

[server.rate_limit]
queries_per_second = 300   # on average, per client network; 0 turns rate limiting off
burst = 1000               # queries a quiet client may send at once
slip = 2                   # see below
ipv4_prefix = 32           # each IPv4 address is its own client network
ipv6_prefix = 64           # each IPv6 /64 is one client network
max_clients = 65536        # client networks tracked at once
exempt = []                # networks never limited, such as ["192.168.0.0/16"]

These are the defaults, generous enough for a busy network behind one router. UDP queries over the limit are dropped, except every slip-th one, which gets an empty answer with the truncated (TC) flag set: a real client retries over TCP, while a spoofed victim gets nothing bigger than the query. slip = 0 drops every limited query; slip = 1 answers each one with TC. Over TCP, DNS over TLS, HTTPS and QUIC, where the handshake has proven the client's address, a query over the limit gets REFUSED instead. Limited queries are not written to the query log, so a flood cannot fill it; goethite_rate_limited_total{protocol=...} counts them. Oblivious DoH is not rate limited, since its peer is a proxy relaying many clients; the connection limits bound it.

Clients on loopback are never limited: their addresses cannot be spoofed from the network, and a local stub resolver (systemd-resolved, dnsmasq) may forward every query of the host. Networks in exempt (at most 256) are not limited either: list the ones you trust, such as a router that forwards every device's queries from one address. When more client networks are active than max_clients, the networks that are not tracked share one limit.

Rate limiting is a safety net, not a firewall: do not expose goethite to the internet unless you mean to run a public resolver. To reach it from outside, serve DNS over TLS, HTTPS or QUIC with require_client_id = true instead of port 53.

goethite serves at most 256 TCP connections at once, and at most 16 from one client (an IPv4 address or an IPv6 /64), so a single host cannot take every connection. DNS over TLS, HTTPS and QUIC connections count against the same limits; a QUIC client's address is checked with a Retry first, so forged packets cannot take a connection. A TCP connection that sends nothing for 10 seconds is closed; an encrypted one after 30 seconds, and its TLS handshake must finish within 10.

[server]
max_tcp_connections = 256
max_tcp_connections_per_client = 16

goethite needs privileges only to bind port 53. It binds its sockets first, before starting any thread, then gives the privileges up:

  • Under systemd, with the unit in deploy/systemd/goethite.service, goethite runs as a dynamic, unprivileged user that may only bind ports below 1024, and gives that up once its sockets are bound. The unit also makes the file system read-only except /var/lib/goethite (keep downloaded lists there with cache_dir = "/var/lib/goethite/lists") and /run/goethite at runtime, with a private /tmp; it hides other processes and devices, allows only IP sockets and filters system calls. systemd-analyze security goethite rates it 1.7, "OK"; what remains is what a DNS server needs, such as Internet sockets.

  • Started as root without systemd, set the user to switch to:

    [server]
    user = "goethite"   # looked up in /etc/passwd

    goethite switches to that user and its primary group, drops supplementary groups, and checks that it cannot become root again. Started as root without user, it logs a warning.

Either way, goethite then empties its capability sets and sets no_new_privs, so it cannot gain privileges again, even by running a program, and confines itself (the sandbox). The filter lists and their cache_dir must be readable, and the cache_dir writable, by the user goethite runs as. Dropping privileges is supported on Linux; on other platforms, which are for development only, server.user is an error.

A floating IP needs privileges the DNS server never gets, so a separate process holds them: goethite vrrp, under deploy/systemd/goethite-vrrp.service. It starts with CAP_NET_RAW and CAP_NET_ADMIN, opens its raw sockets, then keeps only CAP_NET_ADMIN (to add and remove the address) and sets no_new_privs. Its sandbox matches the DNS server's, with netlink and packet sockets allowed and nothing writable; run systemd-analyze security goethite-vrrp to review it.

Once its privileges are gone, and before it handles any query, goethite confines itself on Linux, wherever it runs: under systemd, in a container or started by hand. A bug in a parser, or in a library goethite uses, then cannot reach much beyond what goethite already needs.

  • Files, with Landlock. goethite run may read only the system directories (/usr and the like, for its libraries and time zones), /proc, the config file's directory, the directories of the certificates, keys and services file the config names, and the local lists directory ([filter] local_lists_dir). It may change only the store's directory, the downloaded lists (cache_dir) and its runtime directory, and it opens no new TCP listeners. goethite witness may change only its store's directory and read nothing else; goethite vrrp may open no file at all.
  • System calls, with seccomp. Mounting, loading modules, rebooting, debugging other processes (ptrace), BPF, io_uring, userfaultfd, new namespaces, keyrings and setting the clock are refused with EPERM, as are sockets other than IPv4, IPv6 and Unix ones (goethite vrrp also keeps netlink and packet sockets). The witness and goethite vrrp may not start programs; goethite run may, but only the new goethite of an upgrade, which runs inside the old one's sandbox.

The log says what is in force:

INFO goethite::sandbox::linux: sandboxed: Landlock limits files, seccomp system calls process="goethite run" landlock_abi=6 system_calls_denied=47

Landlock needs Linux 5.13 or newer with Landlock enabled (it is on in Debian, Ubuntu, Fedora and RHEL kernels); older ABIs lack some of its rights, and the log then says the files are limited "as far as this kernel supports". Without Landlock, goethite warns and seccomp still applies. A sandbox around goethite can refuse the system calls that set either up: an older systemd's SystemCallFilter=@system-service (the shipped units allow them), or a container runtime's filter. goethite then warns that it runs without that part of its own sandbox, and the outer one still confines it.

An upgrade starts the new goethite inside the running one's sandbox, so the new one can reach only what the old one could. If you moved the store, the certificates or the lists elsewhere in the config file, restart goethite instead (systemctl restart goethite).

[security] sandbox = false turns the sandbox off, for diagnosing a problem you suspect it causes; the log then warns. The systemd units sandbox goethite from the outside too.

  • Admin token. Without one and without users, the REST API answers loopback only and refuses to listen anywhere else; once a user account exists, loopback clients must sign in too. The config holds only the token's SHA-256 hash. Beyond loopback, serve HTTPS (tls_cert, tls_key); over plain HTTP goethite warns that the token can be sniffed.
  • Users and sessions. People sign in with a user name and password (Argon2id) and an optional TOTP second factor with one-time recovery codes; sessions are HttpOnly; SameSite=Strict cookies that last twelve hours and end everywhere when the user changes. Roles are admin and viewer. Failed sign-ins and reset attempts are rate-limited per address and name. See Users and sign-in.
  • Browsers. The API refuses requests from other web sites and, without a token, requests that do not name this machine, so pages you visit cannot use your browser against it (including by DNS rebinding). The web UI runs under a strict Content Security Policy and holds its session in a cookie scripts cannot read. [api] web_ui = false turns it off.
  • API reference. /api/docs is off unless [api] docs turns it on, answers loopback only, and serves files built into goethite. Its scripts are limited to goethite's own, like the UI's; see Web UI for its style policy.
  • Limits. At most 64 API connections, 10 seconds for the TLS handshake and headers, 1 MiB request bodies and 30 seconds per request.
  • Audit log. Every change to lists, rules, local records, groups, clients, schedules, settings and users is recorded with who made it, from where, and the before and after (GET /api/v1/audit).
  • Query privacy. The query log keeps 7 days by default, can shorten client addresses to their /24 and /56 (anonymize_clients = true) or be turned off. The store file is readable by goethite's user only.

goethite opens connections only to:

  • the upstream resolvers in [[upstream]], for the queries it forwards; or, with recursion, the root servers and the authoritative servers of the names looked up, each shown only as much of a name as it needs;
  • the hosts of downloaded filter lists, over HTTPS, when lists are refreshed (by default every 24 hours), including the default lists of a new node; and the hosts of the recommended lists, for the first 8 KiB of each, when someone opens the Lists page, at most once a day ([filter] directory = false turns it off);
  • adguardteam.github.io, over HTTPS, for the blocked services catalog, when lists are refreshed ([filter] services = false turns it off, services_file reads it from a file instead);
  • api.filterlists.com, over HTTPS, only when someone opens Find lists in the web UI, at most once a day (FilterLists directory; [filter] directory = false turns it off);
  • api.github.com, over HTTPS, only when someone presses Check for updates in the web UI (or POST /api/v1/update/check), to read the newest release's tag; the node sends nothing about itself;
  • the other members of its cluster, if it is in one;
  • the OpenTelemetry collector in [telemetry] endpoint, if one is set, every interval seconds and once when goethite stops. It receives the metrics (counts and states, with no names looked up and no client addresses) and the log lines at INFO and above, a few of which name a name looked up or a client (logs = false keeps them back); and, with traces = true, traces of the slow paths, which name names and servers only with query_details = true.

Names are resolved through goethite's own upstreams. The web UI's pages talk to goethite only; links to list home pages open in a new tab.

goethite answers every query on your network, so by default a failure in filtering never stops it from answering ("fail open"):

  • If its store file cannot be opened (but is not just in use by another goethite), it runs on a temporary store in memory, seeded from the config file's [filter] table, and leaves the file alone for you to look at.
  • If the filter cannot be built, it starts unfiltered; later, a filter that cannot be rebuilt leaves the previous one in place.
  • If checking a name fails (a bug), that query is answered unfiltered.

Each of these appears in problems in GET /api/v1/status, in the TUI and the web UI, and as goethite_degraded 1 in the metrics; failed checks are counted in goethite_filter_failures_total.

Where unfiltered answers are worse than none, fail closed instead:

[filter]
on_failure = "closed"

goethite then refuses to start with a broken store or filter, and answers SERVFAIL to a query whose check failed.