Files
multica/server/internal/auth/cookie_test.go
Bohan Jiang 824d943848 fix(auth): derive cookie Secure flag from FRONTEND_ORIGIN scheme (#1390)
The session cookie's Secure flag was tied to APP_ENV, and the
docker-compose self-host stack defaults APP_ENV to "production". On
plain-HTTP self-host deployments (LAN IP, private network) the browser
silently drops Secure cookies, leaving every subsequent /api/* call
anonymous and surfacing as 401 "auth: no token found" right after a
successful login.

Derive Secure from the scheme of FRONTEND_ORIGIN so HTTPS origins get
Secure cookies and plain-HTTP origins get non-secure cookies the
browser will actually store. Also harden cookieDomain() against the
other common trap: COOKIE_DOMAIN=<ip>, which RFC 6265 forbids and
browsers reject. Log a one-shot warning and fall back to host-only.

Docs: correct the COOKIE_DOMAIN description (it was labelled as
CloudFront-only but applies to session cookies too) and call out the
IP-literal pitfall in SELF_HOSTING_ADVANCED.md, self-hosting.mdx, and
.env.example.

Refs #1321
2026-04-20 19:53:15 +08:00

102 lines
3.1 KiB
Go

package auth
import (
"net/http/httptest"
"testing"
)
func TestIsSecureCookie(t *testing.T) {
cases := []struct {
name string
frontendOrigin string
want bool
}{
{"https origin → Secure", "https://app.example.com", true},
{"https with port", "https://app.example.com:8443", true},
{"http origin → not Secure", "http://192.168.5.5:13000", false},
{"http localhost → not Secure", "http://localhost:3000", false},
{"empty → not Secure", "", false},
{"malformed → not Secure", "::not-a-url", false},
{"uppercase scheme still matches", "HTTPS://app.example.com", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", tc.frontendOrigin)
if got := isSecureCookie(); got != tc.want {
t.Errorf("isSecureCookie() = %v, want %v (FRONTEND_ORIGIN=%q)", got, tc.want, tc.frontendOrigin)
}
})
}
}
func TestCookieDomain(t *testing.T) {
cases := []struct {
name string
env string
want string
}{
{"empty", "", ""},
{"whitespace only", " ", ""},
{"real domain", ".example.com", ".example.com"},
{"bare domain", "example.com", "example.com"},
{"IPv4 rejected", "192.168.5.5", ""},
{"IPv4 with leading dot rejected", ".192.168.5.5", ""},
{"IPv6 rejected", "::1", ""},
{"IPv6 bracketed is not a valid IP literal → passthrough", "[::1]", "[::1]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("COOKIE_DOMAIN", tc.env)
if got := cookieDomain(); got != tc.want {
t.Errorf("cookieDomain() = %q, want %q (COOKIE_DOMAIN=%q)", got, tc.want, tc.env)
}
})
}
}
// TestSetAuthCookies_HTTPSelfHost covers the exact misconfiguration that
// shipped to users on LAN self-host: COOKIE_DOMAIN=<ip> + HTTP FRONTEND_ORIGIN.
// The cookie must land with no Domain attribute and Secure=false so browsers
// actually store it.
func TestSetAuthCookies_HTTPSelfHost(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", "http://192.168.5.5:13000")
t.Setenv("COOKIE_DOMAIN", "192.168.5.5")
rec := httptest.NewRecorder()
if err := SetAuthCookies(rec, "test-token"); err != nil {
t.Fatalf("SetAuthCookies: %v", err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 2 {
t.Fatalf("expected 2 cookies (auth + csrf), got %d", len(cookies))
}
for _, c := range cookies {
if c.Secure {
t.Errorf("cookie %q has Secure=true on HTTP origin; browser would reject it", c.Name)
}
if c.Domain != "" {
t.Errorf("cookie %q has Domain=%q; IP-address Domain would be rejected by the browser (RFC 6265)", c.Name, c.Domain)
}
}
}
func TestSetAuthCookies_HTTPSProduction(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", "https://app.example.com")
t.Setenv("COOKIE_DOMAIN", "app.example.com")
rec := httptest.NewRecorder()
if err := SetAuthCookies(rec, "test-token"); err != nil {
t.Fatalf("SetAuthCookies: %v", err)
}
for _, c := range rec.Result().Cookies() {
if !c.Secure {
t.Errorf("cookie %q missing Secure flag on HTTPS origin", c.Name)
}
if c.Domain != "app.example.com" {
t.Errorf("cookie %q Domain = %q, want %q", c.Name, c.Domain, "app.example.com")
}
}
}