// 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) } }