New release article
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
ec1c9b1598
commit
53fa028057
3 changed files with 255 additions and 0 deletions
255
content/release-0.6.0/index.md
Normal file
255
content/release-0.6.0/index.md
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
---
|
||||||
|
title: "happyDomain 0.6.0: security audit, interface overhaul, and what's next"
|
||||||
|
date: 2026-03-12T16:42:40+07:00
|
||||||
|
draft: false
|
||||||
|
tags:
|
||||||
|
- release
|
||||||
|
- security
|
||||||
|
- ui
|
||||||
|
---
|
||||||
|
|
||||||
|
A few days ago, Anthropic published the details of a [collaboration with
|
||||||
|
Mozilla](https://blog.mozilla.org/en/firefox/hardening-firefox-anthropic-red-team/)
|
||||||
|
in which Claude Opus 4.6 found 22 vulnerabilities in Firefox over two weeks,
|
||||||
|
14 of them rated high-severity by Mozilla, accounting for nearly a fifth of all
|
||||||
|
high-severity Firefox fixes in 2025.\
|
||||||
|
The result was striking enough that we wanted to try the same thing on happyDomain.
|
||||||
|
|
||||||
|
<!-- more -->
|
||||||
|
|
||||||
|
The scope we chose: [authentication](#the-login-flow), [session management](#the-session-store), and the [OIDC/OAuth2 flow](#the-oidc-flow).\
|
||||||
|
Three areas where mistakes tend to be subtle, where the consequences of getting
|
||||||
|
them wrong are serious, and where a fresh pair of eyes (even a synthetic one)
|
||||||
|
can catch things that familiarity makes invisible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security audit: 16 findings, 14 fixes
|
||||||
|
|
||||||
|
The audit ran in three successive passes over the course of two days.
|
||||||
|
Each pass focused on a distinct layer: the password login flow first, then the
|
||||||
|
session store, then OIDC.
|
||||||
|
|
||||||
|
In total, 16 issues came up.\
|
||||||
|
14 were fixed and committed to `master` on the same day they were found.
|
||||||
|
Two were acknowledged and intentionally left as-is after looking at the actual
|
||||||
|
risk (more on that at the end of this section).
|
||||||
|
|
||||||
|
### The login flow
|
||||||
|
|
||||||
|
The most interesting finding here was a timing side-channel.
|
||||||
|
|
||||||
|
When a login attempt used an email address that didn't exist in the database,
|
||||||
|
the server returned almost immediately: the lookup fails, there's nothing more
|
||||||
|
to do.\
|
||||||
|
When the email *did* exist but the password was wrong, bcrypt comparison ran
|
||||||
|
first, adding about 100 ms of latency before the error came back.
|
||||||
|
|
||||||
|
That difference is measurable.\
|
||||||
|
An attacker sending a list of email addresses and watching response times could
|
||||||
|
silently identify which ones have accounts, without ever triggering a
|
||||||
|
failed-login counter.\
|
||||||
|
The fix is to always run a dummy `bcrypt.CompareHashAndPassword` on the
|
||||||
|
not-found path; the call is meaningless, but it equalises the timing of both
|
||||||
|
branches ([`9e8e1b50`](https://git.happydomain.org/happydomain/-/commit/9e8e1b50)).
|
||||||
|
|
||||||
|
Two other findings in the same area were less subtle but no less real.
|
||||||
|
Passwords were hashed with `bcrypt.GenerateFromPassword(pwd, 0)`, which maps to
|
||||||
|
Go's `DefaultCost` of 10.\
|
||||||
|
Cost 12 has become the widely accepted industry baseline; it is roughly
|
||||||
|
four times harder to brute-force offline than cost 10.
|
||||||
|
That's now fixed ([`46a5d15a`](https://git.happydomain.org/happydomain/-/commit/46a5d15a)), and existing passwords stored at cost 10 are
|
||||||
|
transparently re-hashed on the user's next successful login.
|
||||||
|
|
||||||
|
The second: no maximum password length was enforced.\
|
||||||
|
bcrypt silently truncates input at 72 bytes, which creates two problems at once.
|
||||||
|
A user who registers with a 200-character password can later log in with only
|
||||||
|
the first 72 characters.\
|
||||||
|
And an attacker submitting multi-kilobyte passwords can put the server under
|
||||||
|
real CPU pressure before truncation kicks in.\
|
||||||
|
A hard limit of 72 characters is now enforced both at registration and at login
|
||||||
|
([`043b81a3`](https://git.happydomain.org/happydomain/-/commit/043b81a3)).
|
||||||
|
|
||||||
|
Brute-force tracking also had a quiet flaw.
|
||||||
|
All calls to the failure tracker were gated behind a check for a configured
|
||||||
|
captcha provider.\
|
||||||
|
On deployments without one (which is the default), failed login attempts went
|
||||||
|
completely untracked and unlimited.\
|
||||||
|
Tracking now runs unconditionally.\
|
||||||
|
Without a captcha provider, exceeding the threshold returns `HTTP 429` with a
|
||||||
|
`rate_limited: true` flag and the login form disables the submit button ([`5542c58e`](https://git.happydomain.org/happydomain/-/commit/5542c58e)).
|
||||||
|
|
||||||
|
Speaking of captcha providers: this release also introduces **full CAPTCHA
|
||||||
|
support** on both the login and registration flows ([`00900543`](https://git.happydomain.org/happydomain/-/commit/00900543)).\
|
||||||
|
Four providers are supported out of the box: **hCaptcha**, **reCAPTCHA v2**,
|
||||||
|
**Cloudflare Turnstile** and lastly **Altcha**, as a
|
||||||
|
self-hosted, privacy-friendly alternative ([`e0d85265`](https://git.happydomain.org/happydomain/-/commit/e0d85265)).\
|
||||||
|
On the registration endpoint the challenge is always required when a provider
|
||||||
|
is configured; on the login endpoint it is only triggered after a configurable
|
||||||
|
number of consecutive failures for the same IP or email address.
|
||||||
|
|
||||||
|
Finally, session fixation.\
|
||||||
|
After a successful login, `session.Clear()` was called before saving, which
|
||||||
|
zeroes the session's *value map* but leaves the session *ID* untouched.\
|
||||||
|
An attacker who obtains a session ID before authentication (say, by visiting the
|
||||||
|
login page) can plant it in the victim's browser, wait for them to log in, and
|
||||||
|
then replay the same cookie to hijack the authenticated session.\
|
||||||
|
Login now performs a proper two-phase rotation: the old session is deleted from
|
||||||
|
the database, and the gorilla session's `ID` field is reset to `""` so the store
|
||||||
|
generates a fresh random ID on the next save ([`18df8d59`](https://git.happydomain.org/happydomain/-/commit/18df8d59)).
|
||||||
|
|
||||||
|
### The session store
|
||||||
|
|
||||||
|
Two high-severity findings here, and one that had been quietly lurking for a
|
||||||
|
long time.
|
||||||
|
|
||||||
|
In `SessionStore.Save()`, when a session needed to be invalidated (negative
|
||||||
|
`MaxAge`), `storage.DeleteSession()` was called but its return value was
|
||||||
|
discarded entirely.\
|
||||||
|
If the storage layer returned an error, the session record silently stayed in
|
||||||
|
the database.\
|
||||||
|
A client keeping the session ID as a Bearer token could still authenticate after
|
||||||
|
the cookie had been cleared on the browser side.\
|
||||||
|
The error is now propagated: if the deletion fails, `Save()` returns immediately
|
||||||
|
([`502e8710`](https://git.happydomain.org/happydomain/-/commit/502e8710)).
|
||||||
|
|
||||||
|
The session store also accepted IDs from the `Authorization` header, either as a
|
||||||
|
Bearer token or as the Basic Auth username, and forwarded them straight to the
|
||||||
|
storage layer without any format check.\
|
||||||
|
There was nothing stopping an attacker from sending an arbitrarily crafted
|
||||||
|
string as a storage key.\
|
||||||
|
A new `isValidSessionID()` helper now enforces the exact format that
|
||||||
|
`NewSessionID()` produces: standard base32 alphabet `[A-Z2-7]`, exactly 103
|
||||||
|
characters.\
|
||||||
|
Anything that doesn't match is silently ignored, producing a fresh anonymous
|
||||||
|
session instead of a storage probe ([`c889eef9`](https://git.happydomain.org/happydomain/-/commit/c889eef9)).
|
||||||
|
|
||||||
|
The session lifetime deserves its own paragraph.
|
||||||
|
`SESSION_MAX_DURATION` was set to 365 days.\
|
||||||
|
The cookie `MaxAge` was 30 days.\
|
||||||
|
These two numbers were inconsistent with each other and both well outside what
|
||||||
|
any reasonable security policy would accept.\
|
||||||
|
The renewal logic made things worse: it compared the stored expiry against
|
||||||
|
`now + maxAge` and updated it on *every single request*, so sessions effectively
|
||||||
|
never expired as long as the user visited at least once a month.\
|
||||||
|
And the `load()` path read `ExpiresOn` from storage but never actually checked
|
||||||
|
whether it was in the past, so Bearer tokens could outlive their expiry
|
||||||
|
indefinitely.
|
||||||
|
|
||||||
|
All three issues are now addressed in a single commit ([`41fac845`](https://git.happydomain.org/happydomain/-/commit/41fac845)):\
|
||||||
|
`SESSION_MAX_DURATION` is 15 days, a new `SESSION_RENEWAL_THRESHOLD` of 7 days
|
||||||
|
controls when renewal actually triggers, and `load()` now rejects expired
|
||||||
|
sessions by deleting the record and returning an error.
|
||||||
|
|
||||||
|
### The OIDC flow
|
||||||
|
|
||||||
|
The OIDC findings were the most varied in nature.
|
||||||
|
|
||||||
|
The simplest to understand: the `next` query parameter on the login page was
|
||||||
|
decoded and passed directly to `navigate()` without any origin check.
|
||||||
|
Crafting a link like `/login?next=https%3A%2F%2Fevil.com` would redirect the
|
||||||
|
user to an arbitrary external site immediately after authentication, a classic
|
||||||
|
phishing setup.\
|
||||||
|
The fix is straightforward: the decoded value must start with `/` and must *not*
|
||||||
|
start with `//` (which browsers treat as a protocol-relative URL).\
|
||||||
|
Anything else falls back to `/` ([`bd1a2ab1`](https://git.happydomain.org/happydomain/-/commit/bd1a2ab1)).
|
||||||
|
|
||||||
|
The OIDC authorization code flow was missing both of its standard cryptographic
|
||||||
|
protections.\
|
||||||
|
|
||||||
|
No nonce was included in the authorization request, and no nonce claim was
|
||||||
|
verified in the returned ID token.\
|
||||||
|
The nonce is the anti-replay mechanism for ID tokens: without it, a stolen token
|
||||||
|
can be submitted to the callback endpoint in a different session and accepted as
|
||||||
|
valid.\
|
||||||
|
This is code I originally wrote across several projects; the nonce was
|
||||||
|
actually missing from my first implementations and I had since corrected it in
|
||||||
|
other codebases and happyDomain was the one I had overlooked.\
|
||||||
|
Now a 32-byte random nonce is generated in `RedirectOIDC`, stored in the
|
||||||
|
session, and checked against `idToken.Nonce` in `CompleteOIDC` ([`12d08769`](https://git.happydomain.org/happydomain/-/commit/12d08769)).
|
||||||
|
|
||||||
|
No PKCE was used either.\
|
||||||
|
Without it, an attacker who intercepts the authorization code (through a
|
||||||
|
misconfigured redirect URI at the provider, or a network-level intercept) can
|
||||||
|
exchange it for tokens independently of the session that initiated the flow.
|
||||||
|
A PKCE S256 verifier is now generated alongside the nonce and verified during
|
||||||
|
token exchange ([`f968caaf`](https://git.happydomain.org/happydomain/-/commit/f968caaf)).
|
||||||
|
|
||||||
|
Two smaller findings: when no explicit user identifier was present in the OIDC
|
||||||
|
claims, a deterministic user ID was derived from the email using `sha1.Sum()`.
|
||||||
|
SHA-1 has known chosen-prefix collision attacks; it's been replaced with
|
||||||
|
SHA-256 ([`f8fa209c`](https://git.happydomain.org/happydomain/-/commit/f8fa209c)).
|
||||||
|
And all four error paths in `CompleteOIDC` were returning raw error strings from
|
||||||
|
the OIDC library in the HTTP response body, strings that can expose internal
|
||||||
|
URLs, endpoint addresses, and library version details.\
|
||||||
|
Each path now logs the full detail server-side and returns a generic message to
|
||||||
|
the client ([`5f195055`](https://git.happydomain.org/happydomain/-/commit/5f195055)).
|
||||||
|
|
||||||
|
### What wasn't fixed
|
||||||
|
|
||||||
|
Two findings were left open after looking at the actual exposure.
|
||||||
|
|
||||||
|
The `UserAuth` struct has `json:"password,omitempty"` tags on its password hash
|
||||||
|
and recovery key fields, but `[]byte` is never considered empty by the JSON
|
||||||
|
encoder, so both fields would appear in any marshalled output.
|
||||||
|
The struct is used exclusively for database serialisation and backup tooling and
|
||||||
|
is never returned through the API, so the practical risk is low.
|
||||||
|
|
||||||
|
`GetOIDCProvider`, `GetOAuth2Config`, and `NewOIDCProvider` all access
|
||||||
|
`o.OIDCClients[0]` without a bounds check, causing a panic if the slice is empty.
|
||||||
|
The call site already checks `len(o.OIDCClients) > 0` before reaching
|
||||||
|
`NewOIDCProvider`, so it's guarded in practice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User interface overhaul
|
||||||
|
|
||||||
|
A large amount of UI work also landed in 0.6.0.
|
||||||
|
|
||||||
|
The domain and the providers pages have both been rebuilt as interactive,
|
||||||
|
filterable tables.
|
||||||
|
The filter state is synchronised with the URL, so a filtered view is bookmarkable
|
||||||
|
and shareable.\
|
||||||
|
Creating a new domain or a new provider no longer navigates to a separate page;
|
||||||
|
a modal opens inline instead.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Service editing has been reworked more deeply.\
|
||||||
|
Each service now has its own dedicated page with a sidebar showing the associated
|
||||||
|
DNS records and available actions.\
|
||||||
|
The sidebar tracks scroll position in the main content pane so the context
|
||||||
|
follows you as you move through subdomains.\
|
||||||
|
The zone view gained DNS syntax highlighting (via [highlight.js](https://highlightjs.org/)),
|
||||||
|
and zone export moved from a modal to its own page.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
On the provider side, the edit page was redesigned with a sidebar surfacing
|
||||||
|
configuration details at a glance.
|
||||||
|
|
||||||
|
An admin interface is also included in this release, covering users, domains,
|
||||||
|
providers, zones, sessions, and backup/restore. It is not well battle tested,
|
||||||
|
and mostly done with the help of AI, but it's also not exposed on the same
|
||||||
|
user interface. To access it, you'll need to open another port with
|
||||||
|
`-admin-bind`. Pay attention that there is no authentication on this
|
||||||
|
interface/API.
|
||||||
|
|
||||||
|
On the provider side, three new DNS registrars are available: **DNScale**,
|
||||||
|
**Gidinet**, and **Infomaniak**. Thanks to the amazing work of the DNScontrol
|
||||||
|
people.
|
||||||
|
|
||||||
|
Finally, happyDomain can now be hosted at a sub-path of a domain by setting the
|
||||||
|
`--base-path` flag, useful when you want to serve it at `example.com/dns/`
|
||||||
|
rather than the root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's coming next
|
||||||
|
|
||||||
|
The next feature we're working on is domain and service health checks:
|
||||||
|
domain expiration, automated validations that surface common misconfigurations
|
||||||
|
directly in the interface.
|
||||||
|
|
||||||
|
Most of the work has been done during the previous months, we hope to ship
|
||||||
|
those incredible new features soon!
|
||||||
BIN
content/release-0.6.0/new-domain-page.png
Normal file
BIN
content/release-0.6.0/new-domain-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
content/release-0.6.0/new-service-page.png
Normal file
BIN
content/release-0.6.0/new-service-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Loading…
Add table
Add a link
Reference in a new issue