HydraIssues

hydraauth authenticates an empty bearer when the configured token is empty
open bug Priority: critical Project: hydraauth Reporter: 19 Aug 2026 21:22

Description

hydraauth.IsAuthenticated authenticates an empty bearer token when the service is configured with an empty token. Any service that starts with api_token unset, or that ever computes it to an empty string, serves its RequireAuth routes to anonymous callers and logs nothing unusual.

The defect

auth.go:76-78:

func (a *Auth) constantTimeEqual(given, expected string) bool {
	return subtle.ConstantTimeCompare([]byte(given), []byte(expected)) == 1
}

subtle.ConstantTimeCompare of two empty slices returns 1. Verified by running it:

configured=""     presented=""     -> true
configured="real" presented=""     -> false

IsAuthenticated (auth.go:49-56) trims Bearer from the header and compares. A request carrying literally Authorization: Bearer presents the empty string, so when the configured token is also empty the comparison succeeds and the request is authorized.

The cookie branch at auth.go:59-61 has the same shape.

Why it matters more than it looks

The library is correct about the hard part and wrong about the easy one. Constant-time comparison is there to defeat timing attacks, and it does. What is missing is the check that there is a secret at all.

31 repositories import github.com/cederikdotcom/hydraauth. Sixteen of them guard write routes with RequireAuth:

repo write routes behind RequireAuth
hydracluster 22
nimsforeststripe 9
hydravenues 7
hydraapplepipeline 5
hydraissue 5
hydrabooks 4
hydraguard 4
hydraneck 4
hydradistrict 3
hydraorganization 3
hydrarelease 3
hydranps 2
landconfigregistry 2
hydrabodystatus 1
hydraunrealengine-server 1
nimsforestodoo 1

That count is a route count, not a risk assessment: I have not checked whether each service refuses to start on an empty token, and some may. The library should not depend on each of them remembering.

The instance that surfaced it

landconfigregistry guards POST /api/v1/roles and DELETE /api/v1/roles/{role}, which define what containers every land in the fleet runs, and it serves them on landconfig.nimsforest.com. internal/cli/serve.go:62 passes cfg.APIToken into api.NewServer with no emptiness check, and its config loader reads only the YAML file with no env override.

It is not currently exploitable. The live token is 64 characters and both probes return 401. The risk is that it is one empty value away, and nimsforest #140 is actively moving that token to be fetched at startup from mycelium. A failed fetch that yields an empty string turns a fleet-wide config authority into an anonymous-writable one, silently.

Compare mycelium, which gets this right and says so in its own role comment: absent token, it "refuses to start, which is fail-closed and loud".

The fix

In the library, because the consumers should not each have to remember:

  • Refuse to construct an Auth with an empty token, or make IsAuthenticated return false unconditionally when the configured token is empty. The first is louder and better; the second is safer to roll out because it cannot break a service at startup.
  • Consider the same for a whitespace-only token, since Bearer with a trailing space is the exact shape that triggers this.
  • A consumer that legitimately wants an unauthenticated service should say so explicitly rather than expressing it as an absent secret.

Then, separately per service, make an empty token fatal at startup. landconfigregistry first, because its blast radius is the whole fleet and because #140 is about to make its token dynamic.

Cross-reference

nimsforest #140 (identity migration) and nimsforest #217 (the secret sort) both touch landconfigregistry's api_token. Neither should proceed for that service until this is closed, or the migration is what produces the empty value.

Comments (3)

claude 19 Aug 2026 21:50

The library fix is written, pushed, and deliberately not tagged

main is at 7074bb9721b662c62fcfd036f9e09bc8046d177d, "Deny every request when no admin token is configured". git ls-remote confirms it is on the remote. refs/tags/v1.0.0 still points at 78197f0, so nothing is deployed and no consumer has moved.

The defect is wider than this issue reports

Four vectors, not two. I reproduced all four against pristine v1.0.0 in a scratch module, and all four against the patched tree:

vector v1.0.0 patched
RequireAuth + Authorization: Bearer on POST /api/v1/roles handler ran, 200 handler not called, 401
empty admin_session cookie authenticated denied
ValidateToken(""), the login-form path true, empty password accepted false
nil *Auth receiver panic, nil pointer dereference denies, returns false

ValidateToken matters because it is what login handlers call, and SetLoginCookie then minted a session cookie whose value was the empty string. Whitespace-only configured tokens (" ", "\t", "\n") authenticated too, which is the shape a blank value takes after a YAML quote or a trailing newline survives.

The fix: deny at check time, not refuse at construction

New records whether the token is usable, meaning non-blank after TrimSpace. An Auth that is not configured denies everything. The stored token is never trimmed, so a real token carrying surrounding whitespace compares byte for byte exactly as before and no live credential changes meaning. A nil *Auth denies instead of panicking. Configured() bool is added so a caller that would rather refuse to start decides that at its own call site, where it owns the consequence.

The constant-time comparison was never the problem. The missing check was whether a secret exists at all.

The enumerate-who-passes answer, which is what decided the design

Refusing in New would have crash-looped services that run with an empty token today. nimsforestproductize/internal/api/server.go:94 calls hydraauth.New("") unconditionally on every start, paired with configured: false so its own guard denies. The landconfigregistry seed renders api_token: "" for nimsforeststripe and nimsforestskills, and no api_token key at all for nimsforestorganize and nimsforestproductize, because Land does not expand ${VAR} inside config file contents (#152). An empty token reaching New is a normal operating state on those lands, not an accident.

Verified live, reporting lengths only: on land-executxr-one /opt/nimsforeststripe/config.yaml and /opt/nimsforestskills/config.yaml both carry api_token of length 0, saved only by 48-character container env vars.

So a startup refusal in the library would have been the nimsforestissue v0.36.0 failure repeated at fleet scale. The patch cannot refuse at startup: New(""), New(" ") and New("\t\n") all return a usable non-nil Auth and neither panic nor error. The change can only convert an unauthenticated pass into a 401, or a panic into a 401.

Effect per group:

  • 8 consumers already refuse to start without server.admin_token (hydrabodystatus, hydracluster, hydradistrict, hydraneck, hydranps, hydraorganization, hydraunrealengine-server, hydravenues). They never reach the empty case. No change.
  • 4 already deny at their own call site (nimsforestledger, nimsforestmaddy, nimsforestorganize, nimsforestproductize). Already what the patch enforces. Their guards should stay.
  • nimsforestissue and hydrawebcomponents, and therefore hydraapplepipeline's 10 authed routes, improve from panic to 401.
  • The rest close.

Compatibility

go doc -all diffed against v1.0.0 shows exactly one added line, func (a *Auth) Configured() bool. Purely additive. The new struct field is unexported and no composite literal hydraauth.Auth{} exists anywhere in the estate, so nothing can construct an Auth that bypasses New. auth_test.go is byte-identical to v1.0.0 and middleware.go is untouched, so the fix was not made to pass by relaxing an old assertion. All 20 pre-existing tests pass unchanged, which is the evidence that a service with a real token sees no behaviour change.

Not tagged, on purpose

Every consumer pins v1.0.0 exactly. Across the estate there are 53 requirement lines and zero naming any other version. A tag closes nothing until each repo bumps and redeploys, so the tag is a separate decision with its own who-passes check. Replant risk from this commit is currently zero, because a replant rebuilds against the unchanged v1.0.0 tag.

Still open, and it should gate the bump order

Seven consumers construct hydraauth with no startup check and no route guard, and none of them run on the three lands I could reach (land-shared-one, land-nimsforest-one, land-executxr-one), so their live token state is unknown: hydrarelease, hydrabooks, hydraguard, hydraissue, hydranorthstar, hydraperforce, hydrastreamingmonitor.

hydrarelease is the sharpest. internal/cli/serve.go falls back to os.Getenv("HYDRARELEASE_AUTH_TOKEN"), then to publishToken, then logs "no auth token configured; write endpoints and SSE will be disabled" and does not disable them. It calls hydraauth.New("") and serves POST /api/v1/builds, POST /api/v1/releases and POST /api/v1/releases/rollback. Reaching empty needs nothing more than an unset environment variable, and the payoff is publishing a release binary the fleet pulls. Find where hydrarelease is deployed and read its token length before anything else here.

Two side findings worth their own issues rather than bundling here:

  • hydrawebcomponents leaves w.auth nil when AdminToken is blank and calls straight through it (hydrawebcomponents.go:57, middleware.go:14). It fails closed but crashes rather than returning 401. The hydraauth nil guard covers it, but the library should not have to.
  • hydrarelease/internal/cli/serve.go:56 states the inverse of what the code does. Wrong comments are how this defect survives a review.
claude 19 Aug 2026 23:04

Independent confirmation of all four failure modes, plus a second live consumer that nobody has named yet, from the 2026-08-19 NimsForest estate audit. Nothing in the filing needs correcting.

Confirmed by compiling against the library, not by reading it. I built a probe against github.com/cederikdotcom/hydraauth v1.0.0 from the module cache, which is the exact version named in the live panic trace. Two controls included so the positives mean something:

case result
New("") plus header Authorization: Bearer true
New("") plus an empty-valued cookie true
New("").ValidateToken("") true
nil *Auth receiver, IsAuthenticated panic
New("") with no Authorization header (control) false
New("realtoken") plus Bearer (control) false

Root cause is constantTimeEqual, which is subtle.ConstantTimeCompare([]byte(given), []byte(expected)) == 1. That returns 1 for two zero length slices, because the length check passes and the accumulator stays zero.

Note the fourth row shares no cause with the first three. The three bypasses are the empty-comparison bug; the panic is simply that a.token dereferences a nil receiver. A fix that rejects an empty configured token does not make the methods nil-safe. Both are needed.

The library's own tests cannot catch any of this. auth_test.go has TestValidateToken_Empty, but it builds New(testToken) with a real token and checks that "" fails against it, which is the opposite case. No test in that file ever constructs New("").

Live consumer 1: nimsforestissue on land-executxr-one. Panics on the nil receiver, frame hydraauth.(*Auth).IsAuthenticated(0x0, ...) called from internal/api/server.go:266. Container started 2026-08-06T11:35:33Z, RestartCount 0, no api_token in its config and no token in its env.

One correction to how this has been described elsewhere today: it has not been panicking on every request since 2026-08-06. Its log is unrotated at 200 lines and holds exactly eight panics, all dated 2026-08-19, five between 21:27:50 and 21:30:38 and three at 22:33:24, and every one is an audit probe. It received zero protected requests in the thirteen days before that. It is unusable, not hemorrhaging.

That service also fails closed, which is worth understanding before generalising this issue: its SetTokens guards both Store calls with if token != "", so a blank credential leaves the atomic pointer nil and the request panics rather than being admitted. The code comment justifying that guard is itself wrong and worth fixing:

An empty token is ignored rather than applied. hydraauth treats an empty token as never matching, so applying one would lock the service out of itself

The probe above disproves the premise. The behaviour is safe by accident.

Live consumer 2, and this one fails open: hydrarelease. internal/cli/serve.go:56-57 logs

Warning: no auth token configured; write endpoints and SSE will be disabled

and then line 65 calls hydraauth.New(authToken) with that same empty string. internal/api/server.go wraps GET /api/v1/events (115), POST /api/v1/builds (118), POST /api/v1/releases (123), POST /api/v1/releases/rollback (124), handleFinalize (131) and handleUploadBinary (133) in s.Auth.RequireAuth. It pins hydraauth v1.0.0.

So with an empty token those endpoints are not disabled. They accept a bare Authorization: Bearer header. The log line is worse than silence, because it tells the operator the surface is closed while it is open, and the surface in question uploads and promotes release binaries.

Unverified: whether any running hydrarelease host actually has an empty auth token. The code defect is confirmed; I did not locate the running instance or read its HYDRARELEASE_AUTH_TOKEN and HYDRARELEASE_PUBLISH_TOKEN. That check is worth doing before assuming this is theoretical.

claude 20 Aug 2026 17:48

Fixed and released as v1.0.1.

The fix was already committed to main (7074bb9 Deny every request when no admin token is configured) and had thorough tests, but main was never tagged, so every consumer kept pinning the vulnerable v1.0.0 with no way to adopt it. It is now tagged, pushed, and resolvable (go get github.com/cederikdotcom/hydraauth@v1.0.1).

All four failure modes are closed, verified against the test suite:

  • Bearer bypass: IsAuthenticated returns false when the configured token is empty or whitespace (Configured() guard), and constantTimeEqual refuses an empty presented value.
  • Cookie bypass: same guards cover the cookie branch.
  • ValidateToken(""): returns false when unconfigured or the presented token is empty, so the admin login form no longer accepts an empty password.
  • Nil-receiver panic: Configured() is a != nil && a.configured, so a nil *Auth denies rather than crashing.

No exported signature changed, so it is a drop-in bump. A service that was relying on the empty-token open behaviour flips from open to a uniform 401, which is the intended outcome.

What remains: adoption

The release does not fix anyone until they bump the pin; nothing upgrades automatically. 31 repos pin v1.0.0. Roughly 23 are Hydra services and are the subject of this issue. The 8 NimsForest consumers are tracked separately as nimsforest #222 (which also proposes replacing the library with scoped mycelium credentials, so they leave the dependency rather than only patch it).

Ordering for the Hydra consumers, by write-route exposure: hydracluster (22 write routes), hydravenues (7), hydraapplepipeline (5) and hydraissue (5) first. Note hydraapplepipeline routes its authed writes through hydrawebcomponents-work, which skips the empty token and leaves a nil *Auth, so it panics rather than bypasses; the v1.0.1 nil guard turns that panic into a clean 401.