# ๐Ÿ” Security Policy HyperDNS is a **DNS resolver and TLS relay** โ€” by design it answers queries from the public internet on port 53 and can relay TLS sessions on ports 80/443. That places it in a different risk class from an ordinary web application, and this document states what the project does about it, what it deliberately does not, and how to report a problem. --- ## ๐Ÿ“‘ Table of Contents - [Supported Versions](#-supported-versions) - [Reporting a Vulnerability](#-reporting-a-vulnerability) - [Security Model](#-security-model) - [What Is Encrypted, Precisely](#-what-is-encrypted-precisely) - [Built-In Controls](#-built-in-controls) - [Deployment Hardening Checklist](#-deployment-hardening-checklist) - [Known Non-Goals](#-known-non-goals) - [Verification You Can Run Yourself](#-verification-you-can-run-yourself) - [Standards & RFC Compliance](#-standards--rfc-compliance) --- ## โœ… Supported Versions | Version | Status | Security fixes | | :--- | :--- | :--- | | `v2.2.0-beta` (HyperSHIELD) | Current beta | โœ… Yes | | `v2.0.x`โ€“`v2.1.x` beta | Superseded | โŒ Upgrade first | | `v1.x` | End of life | โŒ No | Only the current beta line receives fixes. The project does not backport to earlier betas โ€” the release cadence is fast enough that upgrading is the supported path. --- ## ๐Ÿ“ฎ Reporting a Vulnerability **Do not open a public issue for a security problem.** A public report is a working exploit for every install that has not yet upgraded, and this software runs on the public internet by design. Report privately through **GitHub's private vulnerability reporting** (Security โ†’ *Report a vulnerability* on the repository). If that is unavailable to you, contact the maintainer through the address on the repository profile. **What to include.** The more of this you have, the faster the fix: - The affected version (`hdns -version`). - Whether the panel is on a domain (HTTPS) or loopback-only, and whether `access.allow_all` is on or off โ€” several issues are only reachable in one of those states. - A **reproduction**: the request, the config, or a test that fails. A PoC against a local install is worth more than a description. - Impact as you understand it: what an attacker gains, and what they must already hold to get it. **What to expect.** | Stage | Target | | :--- | :--- | | Acknowledgement of your report | 72 hours | | Initial assessment (confirmed / not reproduced / by design) | 7 days | | Fix or documented mitigation for a confirmed high-severity issue | 30 days | Please **do not** test against infrastructure you do not own. Every issue this project has found can be reproduced against a local install โ€” the test suite and the container recipe in [Verification](#-verification-you-can-run-yourself) are there for exactly that. **Credit.** Reporters are credited in the changelog entry and the release notes unless they ask not to be. --- ## ๐Ÿงญ Security Model Stating the trust boundaries first makes the rest of this document readable. | Principal | Reaches | Trusted for | | :--- | :--- | :--- | | **Anonymous internet** | Port 53 (UDP/TCP), the landing page at `/`, the subscriber routes `/sub/` and `/ip/`, DoT 853, the DoH port (restricted surface: `/dns-query` plus the public portal routes only โ€” v2.2.0 removed the dashboard from it), and the relay on 80/443 | Nothing. Every input is validated; the subscriber token in the path is a credential, not an identity. | | **Subscriber** (holds a `/sub/` link) | Read-only account overview. **Cannot** move the IP binding โ€” that needs the out-of-band registration secret. | Only the ability to *read* the account the link names. | | **Subscriber with registration secret** | `POST /ip/` โ€” rebinds one address. | Nothing else: no account creation, no quota change, no policy access. | | **Operator** (authenticated session) | The dashboard below `//dash/` and every `/api/...` route. | Full control of this daemon, including generating credentials. | | **API client** (holds `X-API-Key`) | `/api/v1/...` and `/api/v2/...` and nothing else โ€” the master key is **refused on every dashboard route** (v2.2.0 scoping) except the lockout-recovery unlock. | Provisioning and read access, bounded by the `api_bind` gate. | | **Local OS user on the box** | `master.key` and `data.db` if file permissions allow. | Treated as **compromised-equivalent** for confidentiality โ€” see below. | Two boundaries are load-bearing and worth naming explicitly: - **The admin path is a locator, not a credential.** `//` keeps the dashboard out of blind scans, but anyone who learns the path still faces the login. It is defence in depth, never the control. Treat a leaked path as a reason to rotate it (Settings โ†’ *Hidden Admin Path*), not as a breach. - **Field encryption protects the confidentiality of sealed fields when the database is obtained without its `master.key`.** That is the boundary: a stolen database file alone (a backup, a snapshot, a mis-sent archive) gives up nothing. An attacker who already reads the install directory holds both, and is compromised-equivalent for confidentiality. The daemon warns at startup if it finds a `master.key.bak` or `data.db.bak` beside the live files for exactly this reason. --- ## ๐Ÿ” What Is Encrypted, Precisely `data.db` is a BoltDB file. Secret **fields** inside it are individually sealed with **AES-256-GCM** under the 32-byte key in `master.key`: | Sealed | In the clear (deliberately) | | :--- | :--- | | Subscriber name | Policy presets and their domain lists | | Subscriber bound-IP list | The BoltDB structure itself | | Subscription token | Traffic counters, timestamps | | Registration secret | | | REST API key | | | TLS certificate paths | | | TOTP secret and LDAP bind password | | **The admin password is never stored.** Only a PBKDF2-HMAC-SHA256 verifier (600,000 iterations) is kept, so the password cannot be recovered by reversing the verifier โ€” even with `master.key` in hand. A sufficiently strong policy is what stands between an attacker holding the verifier and an offline guessing attack, which is why new passwords are held to the strength policy; and a lost admin password is a *reset*, not a recovery. Subscription tokens additionally carry a **keyed blind index** alongside the ciphertext, so `/ip/` remains a single constant-time lookup without the token ever being stored in the clear for indexing. The reason presets are in the clear is that they are not secrets: the same list is published in [`PRESET_CATALOG.md`](PRESET_CATALOG.md) and embedded in the binary. Sealing them would cost a decrypt per query on the hot path and protect nothing. **Query logs are never written to disk in any form.** DNS telemetry lives in memory and on the SSE stream only. This is a design constraint, not a setting. --- ## ๐Ÿ›ก Built-In Controls The controls below ship enabled (where noted, they need one setting changed). They are the reason several classes of issue are "by design" rather than bugs. ### Authentication and authorization - **PBKDF2-HMAC-SHA256, 600,000 iterations** for the admin password, with a minimum-strength policy on new passwords (โ‰ฅ10 chars with two classes, โ‰ฅ16 chars, or a non-ASCII script). - **Server-side sessions** in memory, wiped on restart. Tokens carry 32 bytes of `crypto/rand` entropy and are never derived from anything guessable. - **Login lockout**: 3 failures from one address locks it for 10 minutes. The operator can clear it without a restart (`POST /api/auth/unlock`, or TUI entry `[12]`), which requires a session or the API key, plus a TOTP code when 2FA is on. - **TOTP (RFC 6238)** with a proper enrollment flow: the secret is shown once, and confirmation requires a code. When enabled, password changes and API-key rotation also demand a current code โ€” a stolen session plus a stolen password is not enough. A validated code is **single-use across every gated endpoint** (login included), so one observed code cannot authorise a rotation, an unlock and a login inside its validity window, and the enrollment URI never leaves the password-gated setup response. - **The master REST key no longer opens the dashboard** (v2.2.0): one credential passing both surfaces meant a leaked REST key was a full panel takeover. It keeps its power on `/api/v1|/api/v2` and one dashboard route โ€” the lockout-recovery unlock the root-local TUI calls; the key-scoping tests pin the refusal on the admin routes and the exception on that one. Key rotation re-asks for the **current password** (dashboard) and the same TOTP gate runs on both surfaces. - **LDAP** (`ldaps://` verified against the system trust store, bounded timeouts, bind/search/bind). Modes: local, LDAP, or LDAP-with-local-fallback. Saving the directory settings re-asks for the current password (the directory IS the login path โ€” repointing it from a bare session is the one credential change a stolen session must not make), and **only the directory entry naming the admin account may authenticate** โ€” the directory validates credentials, the panel picks the account. - **One answer for every credential failure.** Local, LDAP, and second-factor failures are indistinguishable, so the API is not an oracle for which path exists or which account is real. ### Session and API surface - **Configurable idle timeout** (5โ€“1440 minutes, default 15) with a separate 24-hour absolute ceiling that a session cannot extend by being used. - **The REST API binds `127.0.0.1` by default.** Flipping it to `0.0.0.0` puts an admin surface on the internet with the API key as the only thing in front of it; the dashboard says so before it saves. - **The panel fails closed without a trusted certificate.** A configured domain means HTTPS-only, and a self-signed fallback is refused rather than served โ€” the "Not secure" banner is treated as a defect, not a cosmetic issue. With no domain the panel binds loopback only. ### Subscriber surface - **`/sub/` cannot move the binding.** It is a read-only overview, and it does not mutate on a visit, a prefetch, or a chat unfurler's fetch (all of which are detected and suppressed). - **Rebinding requires the 96-bit registration secret**, delivered out-of-band. A leaked portal link alone is read-only material. - **Registration gates**: suspended, expired, and over-quota accounts are refused; an address already bound to a *different* subscription answers 409 **without disabling either account** โ€” shared CGNAT addresses are ordinary on mobile networks, and auto-disabling a paying account over one would be worse than the conflict. - **The dedicated subscriber listener serves an allow-list, not a deny-list.** When a portal port is configured, that listener answers `/sub/`, `/ip/`, `/api/sub/`, and the three portal asset prefixes โ€” and returns a bare 404 for the admin namespace, every `/api/...` route, DoH, and the SPA. Adding a route to the dashboard cannot silently widen it. - **Token and secret failures are one answer**, so the endpoint cannot be walked to enumerate live subscriptions. - **Error pages answer with honest status codes**: an unknown token is 404, an expired plan is 410 โ€” the friendly body stays, the success code does not pretend the resource exists. - **Certificates are issued per surface** (panel, subscription portal, DoT/DoH), each validated against its own name before anything persists; the DoH/DoH transports refuse to serve a certificate for the wrong name. ### Input handling - **html/template everywhere on the subscriber pages**, with the one exception (`ServerDNS`) filtered by a dedicated display validator because it is shown as an address to type into a console. - **Operator CSS is sanitised** before it reaches a subscriber: `