- 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
71 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|