DNS Daddy sits in front of every name lookup on a network. A compromise here is a compromise of everything downstream. We would rather hear about a problem than not.
Do not open a public issue.
Use GitHub's private vulnerability reporting: on this repository, go to Security → Report a vulnerability.
Useful to include: what you found, how to reproduce it, the version
(dnsdaddy -version), and what an attacker gets out of it.
What to expect:
| Acknowledgement | within 3 working days |
| Initial assessment | within 10 working days |
| Fix for a critical issue | as fast as we can, with a coordinated release |
| Credit | in the release notes, unless you would rather not be named |
This is a small project. We will tell you honestly if something will take a while rather than leaving you wondering.
We do not run a paid bug bounty. We will not threaten you for reporting in good faith.
DNS Daddy is an experimental, unaudited, AI-assisted personal project. It has had no independent adversarial security review. Automated scanning runs in CI, and a clean scanner run is evidence of very little — tools find the bugs they have rules for, and design flaws are not among them.
It sits in the resolution path of every lookup on a network, which is a position that deserves more scrutiny than one person can give it. That is the main reason it is public.
What that means practically:
- Treat "no known vulnerabilities" as "nobody qualified has looked hard", because that is what it is.
- The behavioural detectors are experimental and alert-only. They are not a control to depend on.
- docs/capabilities.md is the authoritative statement of what is implemented. If anything in this repository claims more than that page supports, that is a bug worth reporting.
If you have security review experience and are willing to spend an hour attacking this, that is the most valuable thing anyone could contribute.
| Version | Supported |
|---|---|
| Latest release | Yes |
| Anything older | No — upgrade before reporting |
main |
Yes, and reports against it are welcome |
Only the latest release gets fixes. This is a one-person project: maintaining backports would mean fixing things more slowly, and there is no version of that trade where the older branch wins.
Fixes ship as a new release. There is no long-term-support line, and promising one would be a promise nobody could keep.
- Remote code execution, memory corruption, or crashes triggered by a crafted DNS message, a DoH request, or a malicious feed file
- Authentication bypass on the management API or dashboard
- Cache poisoning, or answers being returned for the wrong question
- A client being attributed to the wrong network, and so getting the wrong policy
- Query-log data exposed to an unauthenticated caller
- Blocklist bypass: a listed domain resolving when policy says it should not
- Stored or reflected XSS in the dashboard
- Secrets written to logs, or to disk in recoverable form
- Behavioural findings or query-log data reachable without authentication
- A crafted DNS name that causes unbounded memory or CPU use in the detection engine. Its state is bounded deliberately; a way around that is a vulnerability, not a tuning issue
- A detection finding that changes a DNS answer. Behavioural detection is alert-only by design, and anything that makes it affect resolution is a bug of the most serious kind
- The AD bit being set on a response to a client that did not request it, which would assert an authenticity guarantee the client never asked us to check
- Browser DoH bypass. A device can resolve directly over HTTPS and skip us. This is a property of DNS, documented in integrations.md, not a bug.
- Running an open resolver. If you expose port 53 to the internet without a firewall you will be abused for amplification. Documented in deploy.md.
- Missing DNSSEC validation. DNS Daddy forwards rather than validating, and records the upstream's verdict. This is documented at length in docs/dns-security/dnssec.md, including the point that a lying upstream will set the AD bit happily. Local validation is a feature request, not a vulnerability.
- False positives from behavioural detectors. Every one is marked experimental and none of them block anything. A detector firing on your mail gateway is expected, wanted as a report, and is its own issue template rather than a security issue.
- Evading a behavioural detector. A slow tunnel, a word-list DGA, or a tunnel through an excluded domain will not be detected. docs/capabilities.md and each detector's own documentation say what they miss. Detection gaps that are documented are limitations; undocumented ones are worth telling us about.
- False positives and negatives in third-party feeds. Report those upstream; see threat-intel.md.
- Someone with the admin password doing admin things.
- Missing security headers with no demonstrated impact, or scanner output without a working proof of concept.
A full threat model — assets, trust boundaries, actors, twenty threats, and the residual risk left after each mitigation — is in docs/threat-model.md. In summary:
Assumed trusted: the host, the operator, and anyone holding the admin password or an API token.
Assumed hostile: every DNS query, every DoH request, every byte of every downloaded feed, and any device on the network the resolver serves.
That second list is why parsing is defensive: a malformed feed line is counted and skipped rather than aborting a load, DoH bodies are size-capped before parsing, and query names are normalised before they reach any lookup.
Upstream is encrypted by default. Ships with DNS-over-TLS to Quad9 and
Cloudflare, with certificate names verified. A tls:// upstream with no
#servername derives one from the host rather than silently skipping
verification. The dashboard flags plaintext upstreams in amber, and the resolver
logs a warning at startup.
The dashboard has a strict CSP with no unsafe-inline for script or style.
Dynamic values are applied through the CSSOM, not style= attributes. All
interpolation in the dashboard goes through an escaping template — domain names
in the query log are attacker-controlled strings.
API tokens are stored as SHA-256 hashes and compared in constant time. The
plaintext secret is returned exactly once, at creation. Every token carries the
dnsd_ prefix so a leaked one is recognisable in a log or a secret scanner.
The admin password is bcrypt-hashed. Login attempts are rate limited per source address, resolved through the same trusted-proxy rules as everything else — behind a reverse proxy every request has the same peer, so keying the limiter on the raw connection address would have put the whole internet in one bucket and let one attacker lock the operator out.
Sessions are server-side and revocable. The cookie is 256 bits from
crypto/rand and means nothing on its own; the server stores its SHA-256 with
an expiry, so a copy of the database is not a set of live sessions. This
matters for three things that a self-contained signed cookie cannot do:
-
Logout revokes. The row is deleted, so a cookie captured beforehand stops working immediately rather than at its expiry.
-
Changing the password signs everything out, every browser including the one making the change. An operator who changes the password because they think somebody has been in gets what they were asking for.
-
There is no signing key to lose. Sessions were previously
<expiry>.<hmac(expiry)>, which made<data_dir>/session.keya permanent forgery capability for anyone who read it. That file is now deleted on startup; nothing reads it.What this does not solve: an attacker who steals a live cookie is that session until it expires or is revoked. Nothing short of binding the session to something uncopyable changes that, and this is a self-hosted dashboard.
The management interface binds loopback by default. http.listen is
127.0.0.1:8080, and a wildcard (:8080, 0.0.0.0, [::]) or a globally
routable address is refused unless http.allow_public_bind is also set. A
named private address such as 192.168.1.50:8080 needs no acknowledgement —
typing it is already a statement of intent, and it is how the LAN deployment is
configured.
This is a property of the program, not of the container. The image does set
DNSDADDY_ALLOW_PUBLIC_BIND, because inside a network namespace a wildcard
reaches nothing until Compose publishes the port, and Compose publishes it to
127.0.0.1 — but running that image with --network host removes that
boundary and makes the wildcard real.
The health endpoint answers in two tiers. /api/v1/health is deliberately
unauthenticated, which in the HTTPS deployment makes it the one management path
the reverse proxy publishes to the internet. Every caller gets {"status":"ok"}
and nothing else — not the version, not the uptime, not the size of the threat
index, not whether a configuration reload has failed. An entitled caller gets
all of that: entitled means authenticated, or a peer address that is loopback,
which is how dnsdaddy doctor and the container's own health check read the
live index without a credential.
Entitlement is decided from the address that opened the socket and never from a
forwarding header, so X-Forwarded-For: 127.0.0.1 buys nothing — including
from the reverse proxy itself, whose forwarded requests all originate on the
internet.
No forwarding header is trusted by default. X-Forwarded-For,
X-Real-IP, and X-Forwarded-Proto are honoured only from a peer listed in
http.trusted_proxy_cidrs, because trusting them on a directly exposed port
would let any client claim any source address — and with it, any network's
policy. When a trusted proxy is configured, X-Forwarded-For is walked
right-to-left past our own hops: a client can prepend arbitrary values, so the
left-most entry is attacker-controlled and the right-most untrusted one is the
real client.
It refuses to start as an open resolver. dns.allowed_client_cidrs ships
covering loopback, RFC 1918, CGNAT, link-local, and IPv6 ULA. Emptying it while
bound to a non-loopback address is a startup error unless you set
dns.allow_public_resolver: true. Queries from outside the ACL are REFUSED
before any upstream work and deliberately write no query-log row, so the ACL
cannot be turned into a way to fill the disk. This is redundant with your
firewall on purpose: an open resolver is found and conscripted into
amplification attacks within days, and "the operator was told to firewall it"
is not a control.
ANY queries get a minimal RFC 8482 response rather than being forwarded. ANY replies are large, which is precisely the leverage a DNS amplification attack needs.
Cross-origin state changes are rejected. A request authenticated by session
cookie that changes state must carry a same-origin Origin or Referer.
Bearer tokens are exempt because a browser never attaches them cross-site, so
CSRF does not apply to them.
The session cookie's Secure flag is http.secure_cookies — auto
(default), always, or never. auto sets it whenever the request actually
arrived over TLS. An unconditional Secure flag would make login impossible on
the plain-HTTP LAN deployments this software exists to serve: the browser
accepts the cookie and then never sends it back. Set always once TLS is in
front.
These three rules are the supported deployment envelope for the dashboard and management API. Everything above about cookies and forwarded headers assumes them.
-
Internet-facing management access requires HTTPS. If the dashboard is reachable from the public internet, terminate TLS in front of it and set
http.secure_cookies: always. Over plain HTTP the session cookie — and the password used to obtain it — travel in clear text to every device on the path. -
Forwarded protocol headers are only trusted from a configured proxy.
X-Forwarded-Protois believed only when the peer appears inhttp.trusted_proxy_cidrs, and that setting is only correct when the HTTP listener cannot be reached directly. Bind it to loopback, or firewall it, so the proxy is the only path in. If a client can reach the listener directly while a proxy CIDR is trusted, it can assert its own protocol and source address. -
Plain HTTP is acceptable only on loopback or a private management network you explicitly trust — a home LAN, a management VLAN, or a WireGuard/Tailscale interface. This is a supported mode, not an oversight: it is why
secure_cookiesdefaults toautorather thantrue. It is not acceptable on any interface reachable from the internet.
./deploy/install-docker.sh offers exactly three, and none of them publishes
the management interface in plaintext.
| Management access | Listening publicly | DNS Daddy binds | |
|---|---|---|---|
| 1. Home / LAN | http://<lan-ip>:8080 |
the dashboard, on your LAN | the LAN address |
| 2. Public VPS — SSH tunnel | http://127.0.0.1:8080 through ssh -L |
nothing | loopback |
| 3. Public VPS — HTTPS | https://<name-or-ip> |
Caddy, on 80 and 443 | loopback |
Mode 1 is for a machine with no public address. The installer refuses to publish the dashboard on an address it can see is publicly routable, and warns that a private address on the NIC is not evidence a host has no public one — on AWS, GCP, Azure and Hetzner the public address is NATed onto a private NIC, and that inference has caused a real exposure here before.
Modes 2 and 3 both keep DNS Daddy itself on 127.0.0.1:8080. In mode 3 the
reverse proxy is the internet-facing component and the only thing listening on
a public interface; the application is unchanged between the two.
Mode 3 fails closed. If a publicly trusted certificate cannot be obtained,
the installer restores the previous Caddyfile — or removes the one it
generated — reverts the HTTPS settings in .env, and leaves the deployment in
mode 2's posture. It never falls back to plaintext on a public address, and it
never reports success on the strength of having run the commands: the check is
curl without -k, so a certificate this machine does not trust counts as a
failure.
That last point is not theoretical. Left to itself, Caddy issues a certificate
from its own internal CA for a name it considers unnameable — which would put a
certificate no browser trusts in front of the management interface and let the
installer call it done. The generated IP site pins the public ACME issuer
explicitly so that cannot happen quietly, and success requires a curl that
verifies against this machine's trust store.
Raw-IP HTTPS needs Caddy 2.11 or newer. 2.11 is the first release whose bundled CertMagic knows that Let's Encrypt issues IP-address certificates; before it, the request was refused locally and no order was ever sent. The installer checks the version before writing anything and says how to get a current Caddy rather than producing a parse error. Distribution packages are usually far behind — Debian 13 ships Caddy 2.6.2 — so the upstream repository matters here, and the installer now reports which one it used.
Firewall exposure, for the operator to configure — the installer does not open ports:
53/udp, 53/tcp DNS, only where you intend to serve clients
80/tcp, 443/tcp mode 3 only, for Caddy and the ACME challenge
8080/tcp never publicly, in any mode
Cloud-provider firewalls are separate from ufw and cannot be seen from the
machine. Docker's port publishing bypasses ufw entirely, which is why the
bind address rather than a firewall rule is the control that matters.
The bare /dns-query DoH path requires a token unless you set
http.allow_untokenized_doh. Behind a public reverse proxy it would otherwise
be an open DoH resolver for anyone who finds the path.
Generated Markdown reports escape every database-controlled value. Feed names, network names, locations, policy names, category labels, and blocked domains all pass through a table-cell escape that neutralises pipes, newlines, and raw HTML delimiters. Reports are read by people outside the security team, in applications that render HTML — a feed name is not a safe string.
An unknown DoH token is rejected, not silently fallen back to IP-based attribution. Falling back would quietly apply the wrong policy to a roaming device, which is worse than an error.
file:// blocklist feeds are disabled by default and, when enabled, are
confined to feeds.local_feed_dir — checked both lexically and after symlink
resolution. Feed URLs arrive over the management API, so an unconfined
file:// feed would be an arbitrary file read for anyone holding a session or
an API token.
Query names are length-bounded before the policy engine sees them. The suffix walk is linear in name length, which is the cheap half of an asymmetric attack.
Blocklist matching is exact, not hash-based. A hash collision would silently block a legitimate domain; the memory saved is not worth that.
The systemd unit is confined: no new privileges, read-only system, a
seccomp filter, CAP_NET_BIND_SERVICE and nothing else, and memory limits. The
container runs as an unprivileged user and cannot bind port 53 at all — Compose
publishes the host port instead.
Stated here rather than left to be discovered. Each is a real property of the current code, not a hypothetical.
A stolen live session cookie is that session. Logout and a password change both revoke, and sessions expire after twelve hours, but nothing binds a session to the browser holding it. If a cookie is captured — a shared machine, a proxy log, a backup — it works until one of those three things happens. Changing the admin password is the response, and it is now an effective one.
Raw-IP HTTPS has never been observed issuing a real certificate here. What
is now verified goes further than it did: against Let's Encrypt staging, with
Caddy 2.11.4 and CertMagic v0.25.3, an ACME order for an IP identifier was
accepted and both tls-alpn-01 and http-01 challenges were offered. The
order failed only at the callback — urn:ietf:params:acme:error:connection,
connection refused — because the challenge could not reach the address from the
internet. So the capability is real and the remaining unknown is narrow:
issuance itself, which needs ports 80 and 443 reachable at the address being
certified.
An earlier version of this paragraph rested on ACMEIssuer.PreCheck returning
"proceed", and a real attempt on a public VPS then failed. PreCheck was the
wrong layer to test — it is one gate of several — and the claim was replaced
rather than restated. Rows D4 and D5 of
docs/deployment-matrix.md, with
docs/vps-validation.md as the procedure, are what
close the gap.
The login rate limiter is in-process and in-memory. It resets on restart, and it counts nothing an attacker cannot cause it to forget by waiting fifteen minutes. It raises the cost of online guessing; it is not a lockout, and the admin password's entropy is what actually protects the account.
There is no CSRF token. State-changing cookie-authenticated requests are
protected by SameSite=Lax plus a server-side Origin/Referer check. That
combination is sound for a same-origin single-page dashboard and is what the
tests pin, but a synchroniser token would be the belt to that pair of braces.
Sessions cannot be listed or revoked individually. "Revoke everything" is a password change. A per-session view — when it was created, when it was last used, revoke this one — is data the table already holds and the UI does not yet show.
The installer has now run against a real Docker daemon, in containers rather
than VMs. Clean Debian 13 and Ubuntu 24.04, real dockerd 29.3.1, real
Compose, real Caddy 2.11.4 — options 1 and 2, the HTTPS failure path, port-53
conflict detection, and the resulting sockets and Docker bindings inspected
directly. The shipped Dockerfile has since been built and run as well: the
image reaches its HEALTHCHECK's healthy state, a network created through the
API survives docker restart in its named volume, and /api/v1/health over
the published port answers {"status":"ok"} and nothing more, because the peer
is the Docker bridge rather than loopback.
docs/deployment-matrix.md records exactly what was
checked and how.
What that still does not cover: a hypervisor, a real NIC, a second physical client on a LAN, and a machine with a public address. So ACME issuance has never completed here, IPv6 was tested as classification logic rather than as a live socket (this host has no IPv6 stack), and per-client attribution across a real subnet is unverified.
Forwarded (RFC 7239) is not parsed. That is safe in this direction — an
unparsed header is an ignored one — and there is a test that fails if it ever
starts being honoured without the trusted-peer check being applied to it.
- Port 53 firewalled to networks you control (deploy.md)
-
dns.allowed_client_cidrsnarrowed to the networks you actually serve -
dns.allow_public_resolverleft off unless you really mean it - Dashboard behind TLS, or bound to loopback
-
http.secure_cookies: alwaysonce TLS is in front - Admin password changed from the generated one, and
initial-password.txtdeleted -
http.trusted_proxy_cidrsset to your proxy only, and left empty otherwise -
http.allow_untokenized_dohleft off on anything internet-facing -
feeds.local_feed_dirleft empty unless you usefile://feeds - Upstreams using
tls://orhttps:// - Query-log retention set to what you can justify (privacy.md)
-
/api/v1/healthmonitored, alerting whenstatusisdegraded - Backups running, and restored at least once
Release archives are published with checksums.txt; the installer verifies
against it automatically. Manually:
sha256sum -c checksums.txt --ignore-missingTo trust nothing at all, build from source — no cgo, no npm, and the dashboard is embedded from files in this repository:
git clone https://github.com/jameshoulder/dnsdaddy.git
cd dnsdaddy && make build