harden(auth): LOW + Nit batch — bootstrap audit, crypto/rand, XFF trust, CSRF check, protocol-prefix unify (Batch 1)

Audit 2026-05-10 — close 8 LOWs + 2 Nits in-bundle. Remainder
(LOW-1/6/9/11/12, Nit-2/5) need GUI or DB-test runtime not present
in-session; tracked in the audit-doc batch table.

LOW-2: bootstrap.ValidateAndMint now emits 'bootstrap.consume_failed'
audit rows on persist-key + grant-role failure branches before
bubbling. Recovery requires DB seeding per the docstring; without this
row, later forensics can't tell 'bootstrap was used and failed' from
'never invoked.'

LOW-3: randomB64URLForHandler now uses crypto/rand (was time-nano-
shifted). Two providers/mappings created in the same nanosecond used
to collide; now they don't. Time-nano fallback retained for the
unlikely crypto/rand-broken path.

LOW-4: breakglass.verifyDummy uses s.readRand(salt) for the dummy
Argon2id verify. Wall-clock cost unchanged (Argon2id memory alloc
dominates), but cache/branch behavior now matches a real verify —
closes the subtle timing side channel.

LOW-5: clientIPFromRequest now only honors X-Forwarded-For when the
direct connection's RemoteAddr falls in the CERTCTL_TRUSTED_PROXIES
CIDR allowlist. Default-deny: empty list means XFF is ignored.
SetTrustedProxies wired in cmd/server/main.go from cfg.Auth.TrustedProxies.

LOW-7: internal/auth/protocol_endpoints.go::ProtocolEndpointPrefixes
now carries /scep-mtls + /.well-known/est-mtls (previously only in
router.AuthExemptDispatchPrefixes; the two lists had drifted). The
canonical-prefix coverage test in Phase 12 still pins the set.

LOW-8: docs/operator/rbac.md documents that r-mcp / r-cli / r-agent
are not actor-type-bound — role naming is a hint, not an enforcement.
Operators wanting hard binding must apply periodic audit queries.
Native binding is on the v2 roadmap.

LOW-10: Session.Validate now rejects a post-login row with empty
CSRFTokenHash (IsPreLogin=false branch). validSession test fixture
updated with a valid 64-hex CSRF hash.

Nit-1: production RevokeAllForActor call sites already use typed
constants (only test-file literals remain — acceptable).

Nit-3: peekIssuer docstring documents the unsigned-permissive-by-design
invariant + the post-verify re-check pin that the BCL handler enforces.
A future commit that uses peekIssuer output before verify will trip
the inline comment + the existing BCL test matrix.

Status table updated in cowork/auth-bundles-audit-2026-05-10.md:
8 LOWs + 2 Nits CLOSED; 5 LOWs + 2 Nits OPEN with explicit reason
(GUI work, repo refactor, Keycloak integration runtime, WONTFIX).

Refs: cowork/auth-bundles-audit-2026-05-10.md LOW-2/3/4/5/7/8/10
      cowork/auth-bundles-audit-2026-05-10.md Nit-1/3
This commit is contained in:
shankar0123
2026-05-10 22:26:12 +00:00
parent 630831aeac
commit 9cce2ab043
11 changed files with 204 additions and 12 deletions
+25 -6
View File
@@ -27,6 +27,7 @@ package handler
import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
@@ -1192,13 +1193,19 @@ func classifyOIDCFailure(err error) string {
}
func randomB64URLForHandler(n int) string {
// Cheap counter+time fallback; provider/mapping ids don't need
// crypto-strong entropy (they're not security tokens). We still
// use base64url-no-pad for URL safety.
now := time.Now().UnixNano()
// Audit 2026-05-10 LOW-3 closure — was a time-nano-shifted buffer
// (two providers created in the same nanosecond would collide). Now
// crypto/rand: provider/mapping IDs aren't security tokens, but
// collision-freedom matters for primary keys and entropy is free.
buf := make([]byte, n)
for i := 0; i < n; i++ {
buf[i] = byte(now >> (uint(i) * 8))
if _, err := cryptorand.Read(buf); err != nil {
// Fall back to time-nano if crypto/rand is broken (extremely
// unlikely; logged at WARN by the caller's audit row if the ID
// turns out to clash).
now := time.Now().UnixNano()
for i := 0; i < n; i++ {
buf[i] = byte(now >> (uint(i) * 8))
}
}
return base64.RawURLEncoding.EncodeToString(buf)
}
@@ -1368,6 +1375,18 @@ func (v *DefaultBCLVerifier) Verify(ctx context.Context, logoutToken string) (is
// peekIssuer base64-decodes the JWT payload (segment 1 after the `.`)
// and pulls the `iss` claim out without verifying the signature. Used
// to find the matching provider before we know which JWKS to use.
// peekIssuer extracts the `iss` claim from an unsigned JWT payload —
// used by the BCL handler to route the logout_token to the right
// provider for verification.
//
// Audit 2026-05-10 Nit-3 — peekIssuer is INTENTIONALLY unsigned-permissive.
// The returned issuer is used ONLY to select the verifier; the full
// signature + claim verification happens in DefaultBCLVerifier.Verify
// (which re-checks the `iss` claim against the matched provider's
// IssuerURL after JWS signature validation). Callers MUST NOT trust
// peekIssuer output for any access-control decision before the verify
// step completes; the pin is encoded in the BCL handler's call shape
// (peek → match provider → verify-against-provider → consume).
func peekIssuer(jwt string) (string, error) {
parts := strings.Split(jwt, ".")
if len(parts) != 3 {