Files
summercms/modules/surf/transport_smoke_test.go
Jakub Zych e7659f2f4b feat(19.3-02): cookie token refresh across subdomains with hardened credentialed CORS
- bouncer: CookieConfig from auth.cookie.* (LoadCookieConfig validates domain,
  same_site none without Secure, secure false in production), TokenCookie with
  Max-Age = refresh window, ForgetTokenCookie, CookieRefreshHandler and
  CookieLogoutHandler with an app-supplied writer
- surf: http.cors refuses credentials with a * origin and bad or PHP-delimited
  patterns at boot, turns wildcard origins into anchored patterns, echoes the
  exact origin and sends Vary: Origin on every dynamic CORS answer; the
  non-credentialed * answer is unchanged
- smoke tests for the www -> api refresh and the refused wildcard config
2026-10-15 17:49:35 +02:00

185 lines
5.8 KiB
Go

package surf
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// Smoke tests for plan 02 of phase 19.3 (cookie transport, credentialed CORS,
// CORS profiles, Turnstile, api-docs); plan 05 owns full coverage.
const transportBodyLimits = `body_limits:
default_bytes: 1048576
upload_bytes: 1048576
`
// transportApp builds an app from config files (name -> YAML body) in
// environment env.
func transportApp(t *testing.T, env string, files map[string]string) *backpack.App {
t.Helper()
dir := t.TempDir()
if _, ok := files["app.yaml"]; !ok {
files["app.yaml"] = "name: transport-test\n"
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=" + env}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
// routesPluginFunc is a test plugin whose routes come from a func.
type routesPluginFunc struct {
id string
routes func(pact.Router)
}
func (p routesPluginFunc) ID() string { return p.id }
func (p routesPluginFunc) Requires() []string { return nil }
func (p routesPluginFunc) Register(*backpack.App) error { return nil }
func (p routesPluginFunc) Boot(*backpack.App) error { return nil }
func (p routesPluginFunc) Routes(r pact.Router) error {
p.routes(r)
return nil
}
func varyHas(h http.Header, value string) bool {
for _, line := range h.Values("Vary") {
for _, v := range strings.Split(line, ",") {
if strings.EqualFold(strings.TrimSpace(v), value) {
return true
}
}
}
return false
}
func TestCookieRefreshAcrossSubdomainsSmoke(t *testing.T) {
const secret = "transport-smoke-secret-with-32-bytes-at-least"
const issuer = "https://api.example.com/api/auth/login"
app := transportApp(t, "development", map[string]string{
"app.yaml": "name: transport-test\nurl: https://api.example.com\n",
"auth.yaml": "cookie:\n domain: example.com\n",
"http.yaml": transportBodyLimits + `cors:
paths: ["api/*"]
allowed_origins: ["https://www.example.com"]
allowed_methods: ["*"]
allowed_headers: ["*"]
supports_credentials: true
`,
})
cookieCfg, err := bouncer.LoadCookieConfig(app.Config)
if err != nil {
t.Fatal(err)
}
refreshTTL := 20160 * time.Minute
bl := bouncer.NewMemoryBlacklist()
refresh, err := bouncer.CookieRefreshHandler(bouncer.CookieRefreshOptions{
Cookie: cookieCfg,
RefreshTTL: refreshTTL,
Refresh: func(_ context.Context, token string) (string, error) {
return bouncer.Refresh(secret, token, refreshTTL, bl, 0, issuer)
},
Write: func(w http.ResponseWriter, _ *http.Request, res bouncer.CookieResult) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Status)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": res.Err == nil})
},
})
if err != nil {
t.Fatal(err)
}
plugin := routesPluginFunc{id: "acme.auth", routes: func(r pact.Router) {
r.Post("/api/auth/refresh", refresh.ServeHTTP)
}}
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
old, oldJTI, err := bouncer.Mint(secret, "42", issuer, time.Hour)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "https://api.example.com/api/auth/refresh", nil)
req.Header.Set("Origin", "https://www.example.com")
req.AddCookie(&http.Cookie{Name: "token", Value: old})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("refresh status = %d body %s", rec.Code, rec.Body.String())
}
setCookie := rec.Header().Get("Set-Cookie")
c, err := http.ParseSetCookie(setCookie)
if err != nil {
t.Fatalf("Set-Cookie %q: %v", setCookie, err)
}
if c.Name != "token" || c.Value == "" || c.Value == old {
t.Fatalf("cookie %s=%q, want a rotated token", c.Name, c.Value)
}
for _, want := range []string{"Domain=example.com", "Path=/", "Max-Age=1209600", "HttpOnly", "Secure", "SameSite=Lax"} {
if !strings.Contains(setCookie, want) {
t.Errorf("Set-Cookie %q lacks %s", setCookie, want)
}
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" {
t.Errorf("Allow-Origin = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("Allow-Credentials = %q", got)
}
if !varyHas(rec.Header(), "Origin") {
t.Errorf("Vary = %q, want Origin", rec.Header().Values("Vary"))
}
if revoked, _ := bl.IsBlacklisted(context.Background(), oldJTI); !revoked {
t.Error("the old jti is not blacklisted")
}
pre := httptest.NewRequest(http.MethodOptions, "https://api.example.com/api/auth/refresh", nil)
pre.Header.Set("Origin", "https://www.example.com")
pre.Header.Set("Access-Control-Request-Method", "POST")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, pre)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d", rec.Code)
}
if !varyHas(rec.Header(), "Origin") {
t.Errorf("preflight Vary = %q, want Origin", rec.Header().Values("Vary"))
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" {
t.Errorf("preflight Allow-Origin = %q", got)
}
}
func TestCORSCredentialsWildcardRefusedSmoke(t *testing.T) {
app := transportApp(t, "development", map[string]string{
"http.yaml": transportBodyLimits + `cors:
paths: ["api/*"]
allowed_origins: ["*"]
supports_credentials: true
`,
})
_, err := BuildRouter(app, nil)
if err == nil || !strings.Contains(err.Error(), "http.cors") {
t.Fatalf("BuildRouter error = %v, want one naming http.cors", err)
}
}