auth: the duplicate-cookie line hides a link's token (audit A4)

Security audit 2026-10-17, finding A4 (low), stage 0. The line logged when
a request carries two unprefixed session cookies printed r.URL.Path raw,
so GET /confirm/<token> wrote the token to the log (reachable only with
PANEL_COOKIE_SECURE=false, where a sibling host can plant the bare name).
loggedPath moves from internal/web to internal/web/auth as LoggedPath
(web imports auth, not the other way) and both lines - the origin check's
refusal and the duplicate-cookie one - go through it.

Tests: TestDuplicateCookieLineHidesALinksToken; TestLoggedPathHidesTokens
moved with the function. go test ./internal/web ./internal/web/auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 21:35:32 +03:00
co-authored by Claude Opus 5.5
parent da2e8b2ffe
commit 5210cb19f2
7 changed files with 46 additions and 19 deletions
+20
View File
@@ -3,9 +3,11 @@ package auth
import (
"database/sql"
"errors"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
@@ -88,6 +90,24 @@ func TestSessionTokenRejectsDuplicates(t *testing.T) {
}
}
// The duplicate-cookie line names the request's path, and a link's token is
// not written to the log with it (audit 2026-10-17, A4).
func TestDuplicateCookieLineHidesALinksToken(t *testing.T) {
m := testModule(t, false)
var buf strings.Builder
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/confirm/the-secret-token-of-a-link", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
if _, ok := m.sessionToken(r); ok {
t.Fatal("duplicate cookies accepted")
}
if got := buf.String(); strings.Contains(got, "the-secret-token-of-a-link") || !strings.Contains(got, "GET /confirm/… carries 2 cookies") {
t.Errorf("logged %q", got)
}
}
func TestSessionTokenReadsOneCookie(t *testing.T) {
m := testModule(t, true)
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
+1 -1
View File
@@ -55,7 +55,7 @@ func (m *Module) sessionToken(r *http.Request) (string, bool) {
// name cannot be planted that way (see sessionCookiePrefixed).
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name)
r.Method, LoggedPath(r.URL.Path), n, name)
return "", false
}
}
+14
View File
@@ -400,3 +400,17 @@ func loginInfo(r *http.Request) string {
}
return ""
}
// LoggedPath is a request's path as the log may hold it: the token of a
// password-reset or confirmation link is a secret, and the log is read by
// more than the person the link went to (security review, 2026-10-17).
// Every log line that names a request's path goes through it: the origin
// check's refusal and the duplicate-cookie line (audit 2026-10-17, A4).
func LoggedPath(p string) string {
for _, prefix := range []string{"/login/reset/", "/confirm/"} {
if strings.HasPrefix(p, prefix) && len(p) > len(prefix) {
return prefix + "…"
}
}
return p
}
+19
View File
@@ -0,0 +1,19 @@
package auth
import "testing"
// A rejected request's path is logged without the token of a link
// (security review, 2026-10-17).
func TestLoggedPathHidesTokens(t *testing.T) {
for in, want := range map[string]string{
"/login/reset/abcDEF123": "/login/reset/…",
"/confirm/abcDEF123": "/confirm/…",
"/login/reset": "/login/reset",
"/confirm/": "/confirm/",
"/account/profile": "/account/profile",
} {
if got := LoggedPath(in); got != want {
t.Errorf("LoggedPath(%q) = %q, want %q", in, got, want)
}
}
}