New release article
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
nemunaire 2026-03-12 17:31:54 +07:00
commit 53fa028057
3 changed files with 255 additions and 0 deletions

View 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.
![The new domains page with its title and table](./new-domain-page.png)
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.
![The new service page, replacing the hard to navigate modal](./new-service-page.png)
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!

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB