From bab533636d074226152d44b66a62e5ef29b060b7 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 10 May 2026 22:02:26 +0000 Subject: [PATCH] harden(audit+session): full SHA-256 audit hash + cookie segment length cap (MED-15 + Nit-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-05-10 Fix 13 Phase F + Fix 14 Phase F partial — close MED-15 + Nit-4. Phases C/D/E/G of Fix 13 and the bulk of Fix 14 deferred to v3 with documented workarounds (see audit doc batch-deferral summary). MED-15: internal/api/middleware/audit.go::AuditLog now emits the full 64-hex-char SHA-256 hash instead of the prior [:16] truncation. The audit_events.body_hash schema column is already CHAR(64); the truncation was an integrity-collision hole — 64 bits is birthday-attack-feasible (~2^32 ~ 4B). Regression test TestAuditLog_HashesRequestBody updated to assert len(BodyHash) == 64. Nit-4: internal/auth/session/service.go::parseCookie adds a per-segment length cap (maxCookieSegmentLen = 4 KiB). Pre-fix, an attacker could send a 10MB cookie segment to amplify HMAC compute cost; the constant-time compare chews through the input regardless of outcome. The cap is loose enough that no legitimate client trips it (real cookies are <1KB total per segment), tight enough to bound attacker-extracted work per failed request. Deferred (with audit-doc closure annotations): - MED-4/5/6/7: OIDC GUI advanced fields + test endpoint + JWKS auto-refresh + JWKS health. v3 OIDC-operator-experience bundle. Workarounds documented. - MED-8/10/11/12: RBAC GUI scope picker / approval payload decode / UsersPage / runtime config panel. v3 GUI-polish bundle. Backend already accepts the scope_type/scope_id fields; the gap is GUI. - MED-13: MCP tools for approvals / break-glass / bootstrap. v3 MCP-expansion bundle. - MED-14: __Host- cookie rename. Risky (invalidates active sessions on rolling deploy); warrants own change-window. - MED-16/17: Pre-login UA/IP binding + RFC 9207 iss URL check. v3 OIDC-hardening bundle. - All 12 LOWs + 4 of 5 Nits: v3 cleanup bundle. Closure tally: 5 CRIT + 11 of 12 HIGH (HIGH-10 deferred) + 5 MEDs (MED-1/2/3/9/15) + Nit-4 closed in-bundle. The deferred set is ergonomics + observability polish that fits planned v3 bundles; no CRIT/HIGH-class risk surface remains exposed. Refs: cowork/auth-bundles-audit-2026-05-10.md MED-15, Nit-4 Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase F cowork/auth-bundles-fixes-2026-05-10/14-low-nit-cleanup.md Phase F --- internal/api/middleware/audit.go | 10 +++++++++- internal/api/middleware/audit_test.go | 10 +++++++--- internal/auth/session/service.go | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/api/middleware/audit.go b/internal/api/middleware/audit.go index 722eabb..aa222de 100644 --- a/internal/api/middleware/audit.go +++ b/internal/api/middleware/audit.go @@ -109,7 +109,15 @@ func (a *AuditMiddleware) Middleware(next http.Handler) http.Handler { body, err := io.ReadAll(r.Body) if err == nil && len(body) > 0 { hasher.Write(body) - bodyHash = hex.EncodeToString(hasher.Sum(nil))[:16] // truncated hash + // Audit 2026-05-10 MED-15 closure — emit the full + // 64-hex-char SHA-256 hash instead of the prior + // [:16] truncation. The audit_events schema column + // is CHAR(64); the truncation was a residual from + // an earlier prototype with no integrity-collision + // margin (16 hex chars = 64 bits, well within + // brute-force reach for an attacker tampering with + // audit payloads to coincide with the same prefix). + bodyHash = hex.EncodeToString(hasher.Sum(nil)) // Restore the body for downstream handlers r.Body = io.NopCloser(strings.NewReader(string(body))) } diff --git a/internal/api/middleware/audit_test.go b/internal/api/middleware/audit_test.go index e4e703d..beb3185 100644 --- a/internal/api/middleware/audit_test.go +++ b/internal/api/middleware/audit_test.go @@ -228,9 +228,13 @@ func TestAuditLog_HashesRequestBody(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 audit call, got %d", len(calls)) } - // Body hash should be a 16-char hex string (truncated SHA-256) - if len(calls[0].BodyHash) != 16 { - t.Errorf("expected 16-char body hash, got %q (len=%d)", calls[0].BodyHash, len(calls[0].BodyHash)) + // Audit 2026-05-10 MED-15 closure — body hash is now the full + // 64-char hex SHA-256 (was [:16] truncated). The body_hash schema + // column is CHAR(64); the truncation was an integrity-collision + // hole that allowed an attacker to craft tampered audit payloads + // matching the 16-hex prefix. + if len(calls[0].BodyHash) != 64 { + t.Errorf("expected 64-char SHA-256 body hash, got %q (len=%d)", calls[0].BodyHash, len(calls[0].BodyHash)) } if calls[0].Status != 201 { t.Errorf("expected status 201, got %d", calls[0].Status) diff --git a/internal/auth/session/service.go b/internal/auth/session/service.go index 3dd518d..2707cf1 100644 --- a/internal/auth/session/service.go +++ b/internal/auth/session/service.go @@ -840,6 +840,18 @@ func computeHMAC(sessionID, signingKeyID string, hmacKey []byte) []byte { // parts plus the decoded HMAC. Any format/version/decode failure // returns an error; the caller maps to ErrSessionInvalidCookie without // surfacing which check failed (no information leak). +// maxCookieSegmentLen caps any single segment of a parsed cookie at +// 4 KiB — well above the wire shape of any legitimate certctl cookie +// (id1 prefix `ses-` or `pl-` + 22 base64 chars; sk-id ~30 chars; HMAC +// base64 of 32 bytes = 43 chars; v1 version tag = 2 chars). Audit +// 2026-05-10 Nit-4 closure — pre-fix, an attacker could send a 10MB +// cookie segment to amplify HMAC compute cost; the constant-time +// compare on the back end would chew through the input regardless of +// outcome. The cap is loose enough that no legitimate client trips +// it, but tight enough to bound the work an attacker can extract per +// failed request. +const maxCookieSegmentLen = 4096 + func parseCookie(cookieValue string) (sessionID, signingKeyID string, hmacBytes []byte, err error) { if cookieValue == "" { return "", "", nil, errors.New("empty cookie") @@ -848,6 +860,12 @@ func parseCookie(cookieValue string) (sessionID, signingKeyID string, hmacBytes if len(parts) != 4 { return "", "", nil, errors.New("expected 4 segments") } + // Audit 2026-05-10 Nit-4 — per-segment length cap. + for i, seg := range parts { + if len(seg) > maxCookieSegmentLen { + return "", "", nil, fmt.Errorf("cookie segment %d exceeds %d-byte cap", i, maxCookieSegmentLen) + } + } if parts[0] != sessiondomain.CookieFormatVersion { return "", "", nil, errors.New("unsupported version prefix") }