Files
member-console/internal/auth/auth_logging_test.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00

71 lines
2.6 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Log injection, found by the 2026-09 security audit: the callback logged the
// state it was handed with a %s verb, so a state carrying newlines wrote
// additional lines into the log and could forge records around it. The state
// is attacker-controlled -- it arrives in the query string of a URL anyone can
// send a person to.
//
// What closes it is slog rather than the attribute alone: both handlers the
// project runs (text in development, JSON in production) escape a newline
// wherever it appears, in an attribute's value and in the message. Measured
// on Go 1.27.1: `log.Printf("...: %s", state)` writes two records for the
// value below, and every slog form writes one.
//
// So this test guards the migration itself. It fails if internal/auth goes
// back to the standard logger's raw interpolation, which is the mistake that
// produced the finding, and it holds whichever slog form a later author
// reaches for.
func TestAStateCarryingNewlinesStaysOneLogRecord(t *testing.T) {
cfg := newTestConfig()
ctx, logs := captureLog(t)
forged := "abc\nlevel=INFO msg=\"sign-in complete\" email=attacker@example.test\nmore"
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, "the-state-login-stored")
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
req := httptest.NewRequest(http.MethodGet, "/callback", nil)
q := req.URL.Query()
q.Set("state", forged)
req.URL.RawQuery = q.Encode()
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
written := strings.TrimSuffix(logs.String(), "\n")
if written == "" {
t.Fatal("the refusal logged nothing")
}
if got := strings.Count(written, "\n"); got != 0 {
t.Errorf("the refusal wrote %d log records, want 1:\n%s", got+1, written)
}
if strings.Contains(written, `msg="sign-in complete"`) {
t.Errorf("a forged record survived into the log:\n%s", written)
}
if !strings.Contains(written, "callback state does not match the session") {
t.Errorf("the refusal's own message is missing:\n%s", written)
}
}