Skip to content

fix(session,password,basic)!: close the security audit findings of the session, password and HTTP Basic modules - #120

Merged
euskadi31 merged 17 commits into
masterfrom
fix/audit-session-password-basic
Oct 13, 2026
Merged

euskadi31 merged 17 commits into
masterfrom
fix/audit-session-password-basic

Conversation

@euskadi31

@euskadi31 euskadi31 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the following findings of the internal security audit of 2026-10-12 (commit c5c2ac3): CORE-03, CORE-04 (Medium), CORE-06, CORE-08, CORE-09, CORE-11, CORE-12, CORE-14, CORE-15 (the session, basic and bearer part) (Low), CORE-I7, CORE-I8, CORE-I9 (Informational).

It implements maintainer decision 10 (sessions: strong keys, cookies bound to their name and purpose, an enforced absolute lifetime, exact numbers) and the decisions taken for this PR: the __Host-session default with a literal duplicate-cookie rule (Q1), verify-side refusals with sentinels and documented remedies (Q2), no Unicode normalization inside the hashers (Q3), an optional server-side validator whose outages answer 503 (Q4), a default cap of GOMAXPROCS concurrent verifications (U5) and strict Basic parsing that leaves the empty user-id to custom authenticators (U6).

Goal / Problem

  • CORE-03 — account enumeration by timing. basic.Authenticator answered unknown, disabled, locked and expired accounts before verifying the password: 267 µs against 62.5 ms for a known account with a wrong password at bcrypt cost 10, about 250×, although its godoc promised the opposite. The session-web example's form login had the same leak (262 µs against 66 ms).
  • CORE-04 — weak session keys. session.NewCodec accepted any key, empty included, and derived the AES key with a bare SHA-256: an unset environment variable let anyone mint a cookie for any subject, and a passphrase key could be brute-forced offline from a single cookie.
  • CORE-06 — stored hash parameters trusted. The hashers took the cost from the stored hash. An argon2id hash with t=0, p=0, a missing parameter or an empty tag panicked inside golang.org/x/crypto (fatal without a recovery handler); m=4294967295 made one verification try to allocate 4 TiB; a 1-byte tag accepted 7 of 2000 random passwords; a stored bcrypt cost of 31 asked for days of CPU. Whoever writes or imports a hash controlled the cost of every login against it.
  • CORE-08 — sessions that never end. Manager.Rotate moved the expiry to now + TTL, so periodic rotations kept a 1-hour session alive at +4 h 35; only the expiry written in the cookie was checked, so a 30-day cookie stayed valid on a manager reconfigured with 15 minutes; a stolen cookie could not be revoked before its expiry.
  • CORE-09 — cookies not bound to their context. No AEAD associated data: a cookie minted as shop_session opened as admin_session, and across applications sharing the keys.
  • CORE-11 — lenient Basic parsing. The extractor forwarded NUL, CR LF, ANSI escape sequences, invisible format characters (a right-to-left override or a zero-width space inside a user-id that then displays as another one) and invalid UTF-8 to the UserLoader (log injection and spoofing in the application's lookups and logs), its base64 decoding skipped CR LF, and Name() returned the typed user-id (ALICE, or a password typed in the wrong field) instead of the authenticated subject.
  • CORE-12 — unbounded verification cost. Concurrent verifications were not bounded: 16 concurrent default argon2id verifications allocated 304 MiB. Once every attempt costs a hash (CORE-03), any username drives that cost.
  • CORE-14 — numbers lost precision. Session values decoded as float64: user ID 1234567890123456789 came back as 1234567890123456768, another account. The saml-sp example would have silently stopped enforcing SessionNotOnOrAfter once numbers were decoded exactly.
  • CORE-15 — credentials printed. %+v and encoding/json of a session.Session wrote its CSRF token, raw ID and values (log/slog's JSON handler encodes nested values that way); basic and bearer authentications printed the password, the bearer token and, once authenticated, the application's user record with its password hash; a verified bearer.Authentication kept its token; a failed Authenticate returned its input.
  • CORE-I7 — bcrypt truncation and empty passwords. bcrypt Verify accepted any password whose first 72 bytes matched (NIST SP 800-63B-4 §3.1.1.2: "SHALL verify the entire submitted password") while Hash refused such passwords; empty passwords were hashed and accepted; the docs misstated the default cost, presented the OWASP minimum as RFC 9106's recommendation and documented an ErrMismatch no hasher returns.
  • CORE-I8 — session cookie hygiene. No __Host- prefix, so a sibling host could plant a cookie of the same name that shadowed the user's (cookie tossing, login CSRF); a __Host- name with Domain and Path was emitted although browsers reject it; an oversized session was emitted and dropped by browsers; an invalid name produced an empty Set-Cookie while Login reported success; the docs said the store was "AES-GCM with HMAC" and that HttpOnly kept the CSRF token out of reach of an XSS.
  • CORE-I9 — example hygiene. bearer-jwt served an unauthenticated /token endpoint minting access tokens for anyone; the bearer demos logged a 1-hour token; the session-web sign-in had no CSRF token (login CSRF); basic-http challenged with Bearer; the HTTP servers had no read, write or idle timeouts.

Design

ID Resolution
CORE-03 Every Authenticate that passes Supports runs exactly one hasher.Verify: against the user's hash, or against a dummy hash the configured hasher makes at construction (unknown user, loader failure, account without a hash); the lifecycle flags are read after it. An optional PasswordRehasher loader keeps stored hashes at the hasher's current parameters (a hash at other parameters costs another time). The session-web login goes through basic.Authenticator.
CORE-04 Keys of at least 32 bytes (MinKeySize), single-byte buffers refused, HKDF-SHA256(key, info = "…/session v2 AES-256-GCM") (RFC 5869); generation and rotation documented.
CORE-06 Before any hashing: RFC 9106 §3.1 ranges, each of m, t, p exactly once, salt and tag of 16–64 bytes, an encoded-length bound, and inclusive operator limits (Argon2idLimits: 256 MiB, 10 passes, 16 lanes; WithBCryptMaxCost: 14) → ErrMalformedHash / ErrCostLimitExceeded. Constructors panic on out-of-range configuration.
CORE-08 Deadline = min(cookie ExpiresAt, CreatedAt + the reading Manager's TTL) on every read and write, reported by Get; Rotate keeps creation time and deadline; Touch refuses an expired or nil session. WithValidator for server-side revocation; a validator error wrapping security.ErrUnavailable answers 503, never a silent logout.
CORE-09 Cookie format 2: base64url(0x02 ‖ nonce ‖ AES-256-GCM) with AD = version ‖ len-prefixed cookie name ‖ len-prefixed purpose (WithPurpose); Codec.Encode/Decode unexported; earlier cookies refused.
CORE-11 RFC 7617 §2 / RFC 9110 §11.4: Basic + 1*SP, base64 alphabet checked before strict decoding with canonical padding, UTF-8, no control character (C0/DEL/C1, RFC 8264 §9.12), no format character (Unicode Cf: joiners, soft hyphen, bidirectional controls, BOM, tags) and no line or paragraph separator (Zl, Zp) — after RFC 8264's FreeformClass, the base of RFC 8265's OpaqueString that RFC 7617 §2.1 points to — all refused with one error, bounded before decoding; the same checks in basic.Authenticator (form logins); the empty user-id refused by the Authenticator only (U6); Name() = subject once authenticated, basic before.
CORE-12 At most runtime.GOMAXPROCS(0) verifications at once (WithMaxConcurrentVerifications, UnlimitedVerifications); an attempt whose context ends while it waits answers ErrUnavailable (503), for known and unknown accounts alike; rate limiting documented (NIST SP 800-63B-4 §3.2.2).
CORE-14 UseNumber → json.Number, Session.GetInt64; saml-sp fails closed on an unreadable SessionNotOnOrAfter.
CORE-15 session.Session, the session, basic and bearer authentications: String, Format (every verb), LogValue, and a redacting MarshalJSON for Session (ID fingerprint, value keys, times). WithAuthenticated drops the bearer token; failed Authenticate returns security.Anonymous() (basic, bearer, session).
CORE-I7 bcrypt refuses passwords over 72 bytes (ErrPasswordTooLong) on both sides; Hash("") refused (ErrEmptyPassword), Verify never accepts an empty password; NeedsRehash flags shorter salts and refused hashes; docs corrected; Unicode normalization documented as a decorator (Q3, no x/text).
CORE-I8 __Host-session by default (__Secure-session with a Domain or another Path, session with WithSecure(false)); NewManager panics on a configuration browsers refuse (name, path, domain, prefix rules case-insensitive, SameSite=None without Secure, TTL, idle timeout); ErrCookieTooLarge (name + value > 4096 bytes, rfc6265bis §5.6); a request whose cookies of the session's name open as two or more valid sessions — copies of one session included — has none (Q1 literal rule; remedy for stale Domain/Path duplicates documented); GCM 2^32 budget (NIST SP 800-38D §8.3) and CSRF enforcement documented, doc errors fixed.
CORE-I9 bearer-jwt: no /token, an in-process minter; the three bearer demos write a 15-minute RFC 9068 token to a 0600 file, log its path and remove it on shutdown; session-web: CSRF-protected sign-in with the form's session on a dedicated Manager (__Host-signin), CrossOriginProtection; basic-http: Basic realm="basic-http", charset="UTF-8"; read/write/idle timeouts in every touched example and the README quick start.

New public API: password.Argon2idLimits, DefaultArgon2idLimits, WithArgon2idLimits, Argon2idHasher.Limits, WithBCryptMaxCost, DefaultBCryptMaxCost, BCryptHasher.MaxCost, ErrCostLimitExceeded, ErrPasswordTooLong, ErrEmptyPassword; basic.PasswordRehasher, WithMaxConcurrentVerifications, UnlimitedVerifications; session.MinKeySize, WithPurpose, WithValidator, ErrCookieTooLarge, Session.GetInt64. No new dependency (HKDF from crypto/hkdf).

Breaking change / migration

Each item has a MIGRATION.md section (before/after table plus steps):

  • every existing session cookie is refused once (format 2): users sign in again; keys must be ≥ 32 random bytes; the cookie is __Host-session by default; NewManager panics on an invalid configuration; Codec.Encode/Decode are gone;
  • session lifetime: Rotate keeps the deadline, a lowered TTL applies to issued cookies, Touch fails at the deadline; numbers come back as json.Number (.(float64) assertions become fail-open — use GetInt64);
  • stored hashes above the limits (ErrCostLimitExceeded) need raised limits before import; bcrypt passwords over 72 bytes and empty passwords are refused (a migration wrapper for hashes imported from truncating implementations is documented);
  • basic: malformed credentials and an empty user-id are refused; an account whose password or user-id contains a format or separator character (an emoji sequence joined with U+200D, a pasted soft hyphen or BOM; a Persian or Indic name written with U+200C or U+200D) can no longer sign in with Basic — reset the password, rename the account or give it an alias, and refuse these characters when passwords and user-ids are chosen; Name() is the subject; concurrent verifications are capped;
  • bearer.Authentication.Token() is empty once authenticated; failed authenticators return security.Anonymous().

Tests

  • TDD: every step's red phase comes from the auditors' probes rewritten as repository tests; each probe stops reproducing.
  • Timing: exact invariants (one Verify per attempt, dummy from the configured hasher, refusals hold whatever the hasher answers), a sleep-based lower bound that cannot flake, and real-hasher ratios (interleaved accounts, fastest of 9, bound 0.5; before: 0.0001) — stress-run and under load.
  • Fuzzing: FuzzArgon2idDecode (5 min, 68 M execs), FuzzExtractor (5 min, 67 M execs, then 90 s, 26 M execs with the format/separator property), FuzzManagerGet (90 s, 17 M execs): no failure; seeds run in every go test.
  • Mutation: 194 mutants run per commit (password 34, basic 45, session 62, redaction 14, examples 32, review fixes 7), all killed but three equivalent ones, each argued in the log. The independent review ran 43; its four non-equivalent survivors (an unavailable hasher answered 401 for unknown accounts, argon2id p duplicate and overflow, the cookie-count bound) are killed by new tests.
  • Generated mocks: new tests use the mockery mocks for the interfaces of the package under test (MockUserLoader, MockPasswordUser, MockPasswordRehasher, MockTokenVerifier, MockPrincipalLoader). Hand-written doubles remain for password.Hasher in basic and security.Principal in session (another package's in-package mocks are not in this test binary) and for the redaction tests' user records (the secret must sit in a field fmt would print).
  • Browser suite: make e2e-saml (Tales + Chrome, asserts __Host-session): 16 scenarios, 114 steps passed.
  • Reviews: an independent plan review and an independent code review (0 blocker, 0 major, 4 minor, 4 nits), all resolved: the new sign-in CSRF guidance (a pre-session on the Engine's Manager answered 401 on every page, and the obvious loader workaround failed open — now a dedicated Manager, documented and shown in session-web), three untested guards, two stale field godocs, FuzzManagerGet, anonymous failures in session.Authenticator, and — by the maintainer's decision — format and separator characters refused in Basic credentials.

Checks

make generate sync build test lint check-modules on Go 1.27.2 (golangci-lint v2.14.0): green, 0 issues, make sync changed nothing. govulncheck: no vulnerability in any of the 25 modules — this machine's network blocks the govulncheck binary, so it ran with -db file:// on a fresh vuln.go.dev snapshot; CI runs it online.

Package Coverage master
basic 100.0 % 98.5 %
bearer 100.0 % 98.1 %
password 98.9 % (crypto/rand and post-check GenerateFromPassword failures) 92.5 %
session 97.1 % (HKDF/AES/GCM construction with a valid key and crypto/rand failures: unreachable) 91.8 %
examples/basic-http 79.1 % (examples excluded from the bar: main binds a socket) 76.2 %
examples/session-web 78.6 % 73.1 %
examples/saml-sp 76.6 % 76.8 %
examples/bearer-jwt 50.0 % (graceful shutdown and token file in main) 73.0 %
examples/grpc-bearer 40.7 % 48.5 %
examples/connectrpc-bearer 41.4 % 50.0 %

Follow-ups (out of scope)

  • No rate limiter or lockout ships (LIMITATIONS, NIST SP 800-63B-4 §3.2.2): limit failed attempts at the edge or in an Authenticator wrapper.
  • A server-side session store (Redis/SQL) is not shipped: WithValidator is the revocation hook.

Notes for the maintainer

  • Format and separator characters (review nit 6, your decision): refused in user-ids and passwords alike — user-ids with a zero-width non-joiner or joiner (Persian, Indic names) need a rename or an alias. RFC 8264's FreeformClass admits the zero-width joiner and non-joiner after a virama (CONTEXTJ, some Indic text); they are refused too, as decided — passwords typed with them need a reset as well.
  • Q1 (literal rule): two cookies of the session's name that both open as valid sessions — the same session ID included — leave the request without a session. After changing WithDomain or WithPath while the cookie keeps its name, clear each old variant once with Max-Age=0 (MIGRATION step 5, Manager.Get godoc).
  • Sign-in CSRF: open the sign-in form's session on a dedicated Manager (its own cookie name and purpose), never on the one behind the Engine; a PrincipalLoader must refuse a session without a subject, never map it to a principal (it would be authenticated).
  • Rollout: raise the hasher limits before importing costlier hashes; expect every user to sign in again once (cookie format 2); generate session keys from a cryptographic source.
  • The bearer examples keep the JWT PR's RFC 9068 signing inside this PR's structure (no /token, minter seam, 0600 token file).

…words

The hashers trusted the parameters encoded in a stored hash, so whoever
could write or import a hash controlled the cost of every verification
against it: an argon2id hash with t=0, p=0, a missing parameter or an
empty tag panicked inside golang.org/x/crypto, m=4294967295 made one
verification try to allocate 4 TiB, a 1-byte tag accepted about one
random password in 256, and a stored bcrypt cost of 31 asked for days of
CPU. Verify now decodes and checks the stored hash before any hashing:
RFC 9106 §3.1 ranges (t >= 1, p >= 1, m >= 8p), each of m, t, p exactly
once and nothing else, version 19, salt and tag of 16 to 64 bytes, an
encoded length bounded before parsing (ErrMalformedHash), and inclusive
operator limits on the cost (ErrCostLimitExceeded): argon2id 256 MiB, 10
passes, 16 lanes by default (WithArgon2idLimits), bcrypt cost 14
(WithBCryptMaxCost), both raised to the hasher's own parameters. The
constructors apply the same bounds and panic on static misconfiguration;
NewBCryptHasher no longer clamps an out-of-range cost silently.

bcrypt Verify compared only the first 72 bytes of a longer password while
Hash refused it; NIST SP 800-63B-4 §3.1.1.2 requires the entire password
to be verified, so both now return ErrPasswordTooLong. Empty passwords
are refused by Hash (ErrEmptyPassword) and never accepted by Verify
(RFC 8265 §4.1). NeedsRehash also flags a shorter salt and every hash
Verify refuses. The hashers still apply no Unicode normalization: the
package documentation shows an NFC decorator (SP 800-63B-4 SHOULD).

Documentation fixed: the default bcrypt cost is 10, the argon2id defaults
are the OWASP minimum rather than RFC 9106 §4's recommendation, and
ErrMismatch is never returned by the shipped hashers.

Audit: CORE-06, CORE-I7.
basic.Authenticator returned before verifying the password for unknown,
disabled, locked and expired accounts — about 250 times faster than a
known account with a wrong password at bcrypt cost 10 — so the response
time told which accounts exist, contrary to its documentation (CWE-208).
Every attempt now runs exactly one password verification: unknown users,
loader failures and accounts without a password hash are verified
against a dummy hash that the configured hasher makes at construction,
and the lifecycle flags are read only after the verification. Every
refusal is decided after that work; a failed Authenticate returns
security.Anonymous() instead of its credential-bearing input. A loader
implementing the new PasswordRehasher gets a stored hash that NeedsRehash
upgraded after a successful login, so stored hashes converge on the
hasher's parameters and keep costing what the dummy costs.

Since any username now costs a full hash, an unbounded number of
concurrent attempts could exhaust memory (19 MiB per default Argon2id
verification). At most runtime.GOMAXPROCS(0) verifications run at once by
default (WithMaxConcurrentVerifications, UnlimitedVerifications); an
attempt that gets no slot before its context ends, or whose verification
the context ended, fails with security.ErrUnavailable (503) whatever the
account, so overload reveals nothing either. NewAuthenticator panics on a
nil loader or hasher and when the hasher cannot make the dummy hash.

The documentation now states the guarantees and their residuals (stored
hashes at other parameters, the loader's own latency) and the operator's
part: build the Authenticator once, bound request times, rate-limit
failed attempts (NIST SP 800-63B-4 §3.2.2).

Audit: CORE-03, CORE-12.
The Basic extractor forwarded control characters (NUL, CR LF, ANSI escape
sequences) and invalid UTF-8 in the user-id to the UserLoader — a log
injection vector into the application — and encoding/base64 silently
skipped CR LF inside the credentials. RFC 7617 §2: "The user-id and
password MUST NOT contain any control characters". The extractor now
checks the base64 alphabet before decoding, decodes with canonical
padding (RFC 4648 §4, §3.5), refuses invalid UTF-8 and every control
character (C0, DEL and C1, RFC 8264 §9.12) and bounds the credentials
(4096 base64 characters, 3072 bytes decoded), and accepts several spaces
after the scheme (RFC 9110 §11.4); "Basic" without credentials is
malformed. Refusals wrap ErrBadFormat and ErrInvalidCredentials and never
quote the input.

basic.Authenticator applies the same checks before any lookup — a form
login or an OAuth2 password grant building the value with basic.New gets
them too — and refuses an empty user-id (RFC 8265 §3.1), which the
extractor still lets through for custom authenticators taking API keys as
`-u :KEY`. Malformed input costs no verification: it is a property of the
input, not of an account.

Authentication.Name() returned the user-id as typed ("ALICE" for the
subject "alice", or a password typed in the username field) although Name
must be safe to log: it now returns the authenticated subject, and
"basic" before authentication.

Audit: CORE-11.
session.NewCodec accepted an empty or low-entropy key and derived the AES
key with a bare SHA-256: an unset environment variable let anyone forge a
cookie for any subject, and a passphrase key could be brute-forced
offline. Keys must now be at least 32 random bytes (a single-byte buffer
never filled is refused too) and are expanded with HKDF-SHA256 under a
format label (RFC 5869).

Cookies carried no AEAD associated data, so a cookie minted for
"shop_session" opened as "admin_session", or for another purpose sharing
the keys. Cookie format v2 writes a version byte in clear and binds every
cookie to version, cookie name and an optional purpose (WithPurpose) with
length-prefixed associated data (NIST SP 800-38D); Codec.Encode/Decode
are no longer exported, so only the Manager seals, always bound. The
unversioned format is refused: every existing session cookie is
invalidated once (users sign in again, documented).

Rotate moved the expiry to now + TTL, so periodic rotations kept a
session alive forever, and only the expiry carried by the cookie was
checked. A session now expires at the earlier of its ExpiresAt and
CreatedAt + the reading Manager's TTL, on every read; Rotate keeps the
creation time and deadline, and writes (Login, Touch, Rotate) never
extend it — Touch refuses a session past its deadline instead of writing
it with Max-Age 1, and a nil session instead of panicking. The optional
WithValidator hook revokes stateless sessions server-side; a validator
error wrapping security.ErrUnavailable surfaces as 503 through the
Extractor instead of a silent logout.

Numbers in Values decoded as float64 (1234567890123456789 came back as
...768): they now decode as json.Number, read with Session.GetInt64. The
saml-sp example asserted float64/int64 on its SessionNotOnOrAfter value and
would have silently stopped enforcing it: it now reads it with GetInt64
and ends the session when the value is present but unreadable.

Audit: CORE-04, CORE-08, CORE-09, CORE-14.
The session cookie had no prefix, so a sibling host could plant a cookie
of the same name that shadowed the user's own (cookie tossing, login
CSRF), and misconfigurations produced cookies browsers silently refuse: a
__Host- name with Domain and Path was emitted, an invalid name produced an
empty Set-Cookie while Login reported success, an oversized session was
written and dropped.

The default cookie name is now __Host-session — Secure, Path=/, no Domain
(draft-ietf-httpbis-rfc6265bis §4.1.3.2) — falling back to
__Secure-session with a Domain or another Path and to session with
WithSecure(false), whatever the order of the options. NewManager panics on
a configuration browsers would refuse or that cannot work: a nil codec, a
name that is not an RFC 6265 token, a relative, invalid or over-long path
or domain, __Host-/__Secure- rules broken by the attributes (prefixes
matched case-insensitively, §5.4), SameSite=None without Secure, a TTL that
is not positive, a negative idle timeout. Login, Touch and Rotate refuse a
cookie whose name and value exceed 4096 octets (§5.6) with
ErrCookieTooLarge and write nothing. When a request carries several
cookies of the session's name, only those that open and are live count:
two valid sessions make the request ambiguous and it has none — a tossed
cookie can deny a session, never swap it — and a validator that cannot
check one of them ends the search with 503.

Documentation: the store is AES-256-GCM (an AEAD), not "AES-GCM with
HMAC"; the CSRF token is rendered into pages, so HttpOnly does not keep it
from an XSS; VerifyCSRF must be called on every state-changing request
(sign-in included) behind net/http's CrossOriginProtection; one key seals
at most 2^32 cookies (NIST SP 800-38D §8.3). The opt-in SAML browser
suites assert the new cookie name (make e2e-saml: 16/16 scenarios pass).

Audit: CORE-I8.
The exported fields of a session.Session revealed its CSRF token and raw
ID through %+v and encoding/json — log/slog's JSON handler encodes nested
values with encoding/json — and basic and bearer authentications printed
the password, the bearer token and, once authenticated, the
application's user record (its password hash included).

session.Session now implements String, Format (every verb), LogValue and
a redacting MarshalJSON: the ID as its SHA-256 fingerprint (the
session.id_hash span attribute), the keys of its values but not the
values (tokens, personal data), the times, and never the CSRF token. The
cookie is sealed from an internal payload type, so the redaction applies
to printing and encoding only. session.Authentication, basic.Authentication
and bearer.Authentication print and log their name — the subject, or the
scheme label — their state and authorities only; their JSON was already
{} (unexported fields), now pinned by tests.

A verified bearer.Authentication kept its token for the rest of the
request, contrary to the Token() documentation: WithAuthenticated now
drops it, so every TokenVerifier building its result with
bearer.New(token).WithAuthenticated — jwtsec.BearerVerifier included —
returns a token-free value, and a failed bearer Authenticate returns
security.Anonymous() instead of its input.

Also fixes a flaky test of the previous commit: the cookie-size boundary
depends on the timestamps' RFC 3339 length, so it now runs on a fixed
clock.

Audit: CORE-15.
A request carrying several cookies of the session's name is refused when
they hold different valid sessions (a tossed cookie). Two copies of the
same session — the same cookie set once with and once without a Domain,
say — are one session, not an ambiguity: refusing them signed the user
out. Only valid sessions with different IDs make the request ambiguous.

Audit: CORE-I8.
…eb sign-in

The bearer-jwt example served an unauthenticated /token endpoint that
minted an access token for anyone who asked, and the bearer-jwt,
grpc-bearer and connectrpc-bearer examples printed a bearer token, valid
one hour, to their logs. The demos now mint their token in-process, valid
15 minutes, write it to a file only the current user can read
(os.CreateTemp), log the file's path and remove the file when they stop.
bearer-jwt's newServer returns its minter, so its tests mint tokens
directly — with the expired-token and wrong-scope challenge rows the
transport change left for this one.

The session-web sign-in form carried no CSRF token, so another site could
sign a victim into the attacker's account (login CSRF), and it verified
the password of known users only — 262 us against 66 ms at bcrypt cost
10 — so the response time told which accounts exist. The form now opens a
session for itself and carries its CSRF token, which the sign-in checks;
net/http's CrossOriginProtection refuses cross-origin posts; the
credentials go through basic.Authenticator (one verification per
attempt, malformed input refused); a session opened for the form is not
signed in.

The basic-http example challenged with Bearer although it accepts Basic
credentials only; it now sends Basic realm="basic-http", charset="UTF-8".
The HTTP servers of basic-http, bearer-jwt, connectrpc-bearer and
session-web, and the README quick start, now bound every stage with read,
write and idle timeouts (they had none, or a header timeout only).

Audit: CORE-I9, CORE-03.
CLAUDE.md now states the invariants the previous changes rely on: every
basic.Authenticator attempt spends exactly one password verification and
reads the account state after it, password hashers check a stored hash
before hashing, sessions are format-v2 sealed cookies (keys of at least
32 random bytes, bound to their name and purpose, default __Host-session,
absolute lifetime never extended), and the credential-bearing types print
without their secrets.

The documentation of ambiguous session cookies now matches the behaviour:
a request has no session when its cookies hold two different valid
sessions; copies of one session do not count. The MIGRATION sections name
the previous code and the legacy v0 packages rather than version numbers
that read like releases.

Audit: CORE-03, CORE-04, CORE-08, CORE-09, CORE-I8, CORE-15.
…face doubles

The tests added by the previous changes now use the mockery mocks for the
interfaces of the package under test: basic.UserLoader, PasswordUser and
PasswordRehasher (a rehashing loader embeds the two loader mocks),
bearer.TokenVerifier and session.PrincipalLoader. The expectations make
the tests stricter: an attempt looks its user up exactly once, malformed
credentials never reach the loader, and a stored hash is only rewritten
when one is expected.

Hand-written doubles remain where no generated mock can stand in:
password.Hasher in the basic tests and security.Principal in the session
tests (mockery generates in-package mocks, so another package's mocks
exist only in that package's test binary), and the user records of the
redaction tests, whose secrets must sit in fields fmt would print.

Audit: CORE-03, CORE-11, CORE-12, CORE-15.
A request whose cookies of the session's name open as two or more valid
sessions has no session again, even when they carry the same session ID.
Copies of one session can differ — a copy planted before a sign-in that
skipped Rotate carries the attacker's values under the victim's ID — and
nothing tells which one the browser meant, so accepting same-ID copies
let a planted copy win. This reverts the relaxation of the previous
"copies of one session" change.

Stale duplicates, left by a change of Domain or Path while the cookie
keeps its name, now make every request of the browser sessionless until
the old cookie expires: the Manager.Get documentation, MIGRATION and the
security considerations tell how to clear each old variant once with
Max-Age=0.

Audit: CORE-I8.
… Manager

The CSRF guidance told applications to protect their sign-in form with the
token of a session opened with Login(ctx, c, nil) — on the Manager the
Engine reads. That session has no subject: the Engine extracted it on
every request, the PrincipalLoader refused it as its documentation asks,
and every page of the browser, the sign-in form included, answered 401
for the cookie's lifetime. The obvious workaround, a loader mapping a
session without subject to an anonymous or guest principal, made the
Authenticator return an authenticated value that voter.Authenticated
grants.

The VerifyCSRF, Login and PrincipalLoader documentation, the package
documentation, MIGRATION and the security considerations now open the
form's session on a dedicated Manager — its own cookie name and purpose,
a short TTL — that no Extractor reads, and say that a PrincipalLoader
must refuse a session without a subject. The session-web example does so
(cookie __Host-signin, cleared at sign-in), and a test runs the trap and
the pattern through a session Engine.

Audit: CORE-I8, CORE-I9.
…authentication

Get returned the ExpiresAt carried by the cookie, later than the deadline
the Manager enforces once its TTL was lowered; it now reports the
enforced deadline, the earlier of that ExpiresAt and CreatedAt + TTL. The
Session field documentation said the CSRF token is never exposed to
JavaScript — it is rendered into pages, where an XSS reads it — and that
LastAccessed is refreshed on every load, while the idle timeout keys off
the value written by Login, Touch and Rotate.

Like the basic and bearer authenticators, a failed
session.Authenticator.Authenticate returns security.Anonymous() rather
than its input, the pending value holding the decoded session.

Tests pin the four-cookie bound of a request (four cookies of the
session's name are inspected, five are not), and FuzzManagerGet sends two
arbitrary cookies of the session's name: Get never panics, opens only
sealed cookies, and two copies of one cookie never make a session.

Audit: CORE-08, CORE-I8, CORE-15.
A hasher that cannot verify — a remote HSM or pepper service down —
wraps security.ErrUnavailable, and the Authenticator answers it 503
before any account check. Nothing pinned it: answered 401 for unknown
accounts and 503 for known ones, the status code would tell which
accounts exist during the outage. Every attempt outcome now runs against
such a hasher and must be classified unavailable.

Audit: CORE-03, CORE-12.
Rows for a repeated p, p=256 and p=257 (which a wider parse would
truncate to 1 lane) and a negative p: each is refused as a malformed
hash, as the documentation promises for every parameter.

Audit: CORE-06.
The Basic rules refused control characters only: invisible format
characters — zero-width joiner and space, soft hyphen, bidirectional
overrides and isolates, byte order mark, tags (Unicode Cf) — and the line
and paragraph separators (Zl, Zp) still reached the UserLoader, so a
user-id displaying as another one (with U+202E or U+200B inside) reached the
application's lookups and logs. RFC 7617 §2.1 points to RFC 8265's
OpaqueString and UsernameCasePreserved profiles, whose FreeformClass
(RFC 8264) disallows the separators and the default-ignorable code
points, the joiners outside a virama context included.

The Extractor and the Authenticator (form logins) now refuse these
characters in the user-id and in the password, with the same error as a
control character. Accented letters, emoji without a joiner, variation
selectors and no-break spaces are still accepted.

Breaking: an account whose password contains such a character — an
emoji sequence joined with U+200D, a pasted soft hyphen or byte order
mark — can no longer sign in with Basic. MIGRATION, LIMITATIONS and the
security considerations give the remedy: reset the password, and refuse
these characters when a password is set.

Audit: CORE-11.
session.Authenticator.Authenticate returns security.Anonymous() when it
does not support the authentication it gets, but no test told that
return value from its input: the test passed security.Anonymous() itself.
It now passes a value of another type and asserts the anonymous result,
with the generated PrincipalLoader mock (never called) in place of the
hand-written loader.

The breaking note on format and separator characters named passwords
only: a user-id holding one — a Persian or Indic name written with
ZERO WIDTH NON-JOINER U+200C or ZERO WIDTH JOINER U+200D — is refused
too. CHANGELOG, MIGRATION, LIMITATIONS and the security considerations
now say so, with its remedy: rename the account or give it an alias.

Audit: CORE-11, CORE-15.
@euskadi31
euskadi31 force-pushed the fix/audit-session-password-basic branch from c115707 to 611137e Compare October 13, 2026 07:08
@euskadi31
euskadi31 merged commit 67c44f0 into master Oct 13, 2026
2 checks passed
@euskadi31
euskadi31 deleted the fix/audit-session-password-basic branch October 13, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant