diff --git a/modules/bouncer/cookie_test.go b/modules/bouncer/cookie_test.go index 2a42d7b..d76ad16 100644 --- a/modules/bouncer/cookie_test.go +++ b/modules/bouncer/cookie_test.go @@ -442,3 +442,89 @@ func TestCookieHandlerOptionsValidated(t *testing.T) { t.Fatalf("valid logout options: %v", err) } } + +func TestLoadCookieConfigProductionInferredSecure(t *testing.T) { + for _, tc := range []struct { + name string + env string + values map[string]any + wantErr bool + secure bool + }{ + {"production, http app.url", "production", map[string]any{"app.url": "http://api.example.com"}, true, false}, + {"production, unset app.url", "production", map[string]any{}, true, false}, + {"production, malformed app.url", "production", map[string]any{"app.url": "://not a url"}, true, false}, + {"production, https app.url", "production", map[string]any{"app.url": "https://api.example.com"}, false, true}, + {"production, http app.url and secure true", "production", map[string]any{"app.url": "http://api.example.com", "auth.cookie.secure": true}, false, true}, + {"development, http app.url", "development", map[string]any{"app.url": "http://localhost:8000"}, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg, err := LoadCookieConfig(cookieSource{values: tc.values, env: tc.env}) + if tc.wantErr { + if err == nil { + t.Fatalf("config = %+v, want an error", cfg) + } + for _, key := range []string{"auth.cookie.secure", "app.url"} { + if !strings.Contains(err.Error(), key) { + t.Errorf("error %q does not name %s", err, key) + } + } + return + } + if err != nil { + t.Fatal(err) + } + if cfg.Secure != tc.secure { + t.Fatalf("Secure = %t, want %t", cfg.Secure, tc.secure) + } + }) + } +} + +func TestZeroCookieConfigDefaults(t *testing.T) { + var zero CookieConfig + for name, c := range map[string]*http.Cookie{ + "TokenCookie": zero.TokenCookie("tok", time.Hour), + "ForgetTokenCookie": zero.ForgetTokenCookie(), + } { + if c.Name != DefaultCookieName || c.Path != "/" || c.Domain != "" || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || !c.Secure { + t.Errorf("%s of the zero config = %+v, want token on / with HttpOnly, SameSite=Lax and Secure", name, c) + } + } + partial := CookieConfig{Name: "sid"}.TokenCookie("tok", time.Hour) + if partial.SameSite != http.SameSiteLaxMode || partial.Secure { + t.Errorf("{Name: sid} cookie SameSite %v Secure %t, want Lax and not Secure", partial.SameSite, partial.Secure) + } + + write := func(w http.ResponseWriter, _ *http.Request, res CookieResult) { w.WriteHeader(res.Status) } + refresh, err := CookieRefreshHandler(CookieRefreshOptions{ + RefreshTTL: time.Hour, + Refresh: func(context.Context, string) (string, error) { return "rotated", nil }, + Write: write, + }) + if err != nil { + t.Fatal(err) + } + logout, err := CookieLogoutHandler(CookieLogoutOptions{ + Revoke: func(context.Context, string) error { return nil }, + Write: write, + }) + if err != nil { + t.Fatal(err) + } + for name, h := range map[string]http.Handler{"refresh": refresh, "logout": logout} { + req := httptest.NewRequest(http.MethodPost, "/auth/"+name, nil) + req.AddCookie(&http.Cookie{Name: DefaultCookieName, Value: "old"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + set := rec.Header().Get("Set-Cookie") + if set == "" { + t.Fatalf("%s: no Set-Cookie (status %d)", name, rec.Code) + } + for _, want := range []string{DefaultCookieName + "=", "Path=/", "HttpOnly", "SameSite=Lax", "Secure"} { + if !strings.Contains(set, want) { + t.Errorf("%s: Set-Cookie %q lacks %s", name, set, want) + } + } + } +} diff --git a/modules/cooler/sitecache_test.go b/modules/cooler/sitecache_test.go index 18ce161..64f9b5d 100644 --- a/modules/cooler/sitecache_test.go +++ b/modules/cooler/sitecache_test.go @@ -717,3 +717,162 @@ func TestSiteCachePurgeAsyncCoalesced(t *testing.T) { } assertNoToken(t, logs) } + +// blockingReceiver answers once release is closed (or the request ends). +func blockingReceiver(t *testing.T) (*httptest.Server, func() []purgeHit, func()) { + t.Helper() + release := make(chan struct{}) + var once sync.Once + srv, hits := purgeReceiver(t, func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + _, _ = w.Write([]byte(`{"purged":1}`)) + case <-r.Context().Done(): + } + }) + free := func() { once.Do(func() { close(release) }) } + t.Cleanup(free) + return srv, hits, free +} + +func TestSiteCachePurgeWaitAndClose(t *testing.T) { + t.Run("nil and idle purgers return at once", func(t *testing.T) { + var nilPurger *SiteCachePurger + if err := nilPurger.Wait(context.Background()); err != nil { + t.Fatalf("nil Wait = %v", err) + } + srv, _ := purgeReceiver(t, func(http.ResponseWriter, *http.Request) {}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := newTestPurger(t, srv).Wait(ctx); err != nil { + t.Fatalf("idle Wait under a cancelled context = %v, want nil", err) + } + }) + + t.Run("Wait gives up with the context and the purge is still sent", func(t *testing.T) { + captureLogs(t) + srv, hits, free := blockingReceiver(t) + p := newTestPurger(t, srv) + PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "pending") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := p.Wait(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Wait = %v, want context.DeadlineExceeded", err) + } + free() + waitPurges(t, p) + if got := hits(); len(got) != 1 || got[0].body["slug"] != "pending" { + t.Fatalf("requests = %#v, want the queued purge", got) + } + }) + + install := func(t *testing.T, srv *httptest.Server) (*Handle, *SiteCachePurger) { + t.Helper() + cfg := testConfig(t) + if err := cfg.Set(SiteCachePurgeURLKey, srv.URL+"/api/__site__/cache/purge"); err != nil { + t.Fatal(err) + } + if err := cfg.Set(SiteCachePurgeTokenKey, purgeToken40); err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + h, err := Install(context.Background(), app) + if err != nil { + t.Fatal(err) + } + p := SiteCacheFromApp(app) + if p == nil { + t.Fatal("Install published no purger") + } + return h, p + } + + t.Run("Handle.Close waits for a queued purge", func(t *testing.T) { + captureLogs(t) + srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(200 * time.Millisecond) + _, _ = w.Write([]byte(`{"purged":1}`)) + }) + h, p := install(t, srv) + PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "at-shutdown") + if err := h.Close(); err != nil { + t.Fatal(err) + } + if got := hits(); len(got) != 1 || got[0].body["slug"] != "at-shutdown" { + t.Fatalf("requests after Close = %#v, want the queued purge", got) + } + }) + + t.Run("Handle.Close is bounded when the site hangs", func(t *testing.T) { + captureLogs(t) + srv, _, _ := blockingReceiver(t) + h, p := install(t, srv) + PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "hung") + start := time.Now() + if err := h.Close(); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed > SiteCachePurgeTimeout+time.Second+500*time.Millisecond { + t.Fatalf("Close took %s, want at most %s plus a second", elapsed, SiteCachePurgeTimeout) + } + waitPurges(t, p) + }) +} + +func TestSiteCacheBulkCommitCoalesced(t *testing.T) { + gdb := purgeDB(t) + captureLogs(t) + ctx := context.Background() + + run := func(t *testing.T, save func(p *SiteCachePurger) error, last string) { + t.Helper() + srv, hits, free := blockingReceiver(t) + p := newTestPurger(t, srv) + start := time.Now() + if err := save(p); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("the saves took %s while the site was blocked, want under 500ms", elapsed) + } + free() + waitPurges(t, p) + got := hits() + if len(got) != 2 { + t.Fatalf("%d requests, want 2 (one in flight, one coalesced follow-up)", len(got)) + } + if got[1].body["slug"] != last { + t.Fatalf("follow-up slug = %q, want the last, %q", got[1].body["slug"], last) + } + } + + t.Run("25 purges in one transaction", func(t *testing.T) { + run(t, func(p *SiteCachePurger) error { + return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error { + for i := range 25 { + slug := "bulk-" + strconv.Itoa(i) + if err := tx.Exec(`INSERT INTO purge_posts (slug) VALUES (?)`, slug).Error; err != nil { + return err + } + PurgeSiteCacheAfterCommit(ctx, tx, p, "post", slug) + } + return nil + }) + }, "bulk-24") + }) + + t.Run("10 separate transactions", func(t *testing.T) { + run(t, func(p *SiteCachePurger) error { + for i := range 10 { + slug := "each-" + strconv.Itoa(i) + if err := lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error { + PurgeSiteCacheAfterCommit(ctx, tx, p, "post", slug) + return tx.Exec(`INSERT INTO purge_posts (slug) VALUES (?)`, slug).Error + }); err != nil { + return err + } + } + return nil + }, "each-9") + }) +} diff --git a/modules/surf/apidocs_test.go b/modules/surf/apidocs_test.go index aea7554..e6fcc32 100644 --- a/modules/surf/apidocs_test.go +++ b/modules/surf/apidocs_test.go @@ -305,3 +305,67 @@ func TestAPIDocsTitleEscaped(t *testing.T) { t.Fatal("default title missing") } } + +// cspDirectives splits a Content-Security-Policy value into its directives. +func cspDirectives(csp string) map[string][]string { + out := map[string][]string{} + for _, part := range strings.Split(csp, ";") { + fields := strings.Fields(part) + if len(fields) == 0 { + continue + } + out[fields[0]] = fields[1:] + } + return out +} + +func TestAPIDocsCSPNoHostSource(t *testing.T) { + h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}}) + if err != nil { + t.Fatal(err) + } + nonces := map[string]bool{} + for range 2 { + rec := apiGet(h, "/api-docs") + if rec.Code != http.StatusOK { + t.Fatalf("GET /api-docs = %d", rec.Code) + } + dirs := cspDirectives(rec.Header().Get("Content-Security-Policy")) + script := dirs["script-src"] + if len(script) != 1 || !strings.HasPrefix(script[0], "'nonce-") || !strings.HasSuffix(script[0], "'") { + t.Fatalf("script-src = %q, want exactly one nonce source", script) + } + nonce := strings.TrimSuffix(strings.TrimPrefix(script[0], "'nonce-"), "'") + if nonce == "" { + t.Fatal("empty nonce") + } + for _, d := range []string{"object-src", "base-uri", "frame-ancestors"} { + if got := dirs[d]; len(got) != 1 || got[0] != "'none'" { + t.Errorf("%s = %q, want 'none'", d, got) + } + } + page := rec.Body.String() + tags := regexp.MustCompile(`]*>`).FindAllString(page, -1) + if len(tags) != 3 { + t.Fatalf("script tags = %q, want 3", tags) + } + for _, tag := range tags { + if !strings.Contains(tag, ` nonce="`+nonce+`"`) { + t.Errorf("script tag %q lacks nonce %q", tag, nonce) + } + } + for _, sri := range []string{ + "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW", + "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw", + "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF", + } { + if !strings.Contains(page, `integrity="`+sri+`"`) { + t.Errorf("page lacks integrity %s", sri) + } + } + nonces[nonce] = true + } + if len(nonces) != 2 { + t.Fatal("two requests got the same nonce") + } +} diff --git a/modules/surf/cors_profile_test.go b/modules/surf/cors_profile_test.go index be639ed..9ccbbd2 100644 --- a/modules/surf/cors_profile_test.go +++ b/modules/surf/cors_profile_test.go @@ -718,3 +718,103 @@ func TestTurnstileEmptySecretWarnsAtBoot(t *testing.T) { t.Fatalf("warnings without turnstile routes = %v", got) } } + +func TestCORSCredentialedOriginRules(t *testing.T) { + // Each block lists a good entry first, so a refusal names index [1]. + blocks := []struct{ name, prefix, yamlHead, indent string }{ + {"global", "http.cors", "cors:\n paths: [\"api/*\"]\n", " "}, + {"profile", "http.cors_profiles.spa", "cors_profiles:\n spa:\n", " "}, + } + load := func(t *testing.T, block int, body string) error { + t.Helper() + b := blocks[block] + cfg := writeHTTPConfig(t, b.yamlHead+body) + if b.name == "global" { + _, err := LoadCORSConfig(cfg) + return err + } + _, err := LoadCORSProfiles(cfg) + return err + } + entry := func(indent, key, good, value string, credentials bool) string { + out := indent + key + ": ['" + good + "', '" + value + "']\n" + if credentials { + out += indent + "supports_credentials: true\n" + } + return out + } + const goodOrigin, goodPattern = "https://www.example.com", `^https://www\.example\.com$` + + accepted := []struct{ key, value string }{ + {"allowed_origins", "https://www.example.com"}, + {"allowed_origins", "https://*.example.com"}, + {"allowed_origins", "https://*.api.example.com:8443"}, + {"allowed_origins_patterns", `^https://[a-z]+\.example\.org$`}, + } + refused := []struct{ key, value string }{ + {"allowed_origins", "https://*"}, + {"allowed_origins", "*://*"}, + {"allowed_origins", "https://*example.com"}, + {"allowed_origins", "https://*.com"}, + {"allowed_origins", "http*://www.example.com"}, + {"allowed_origins_patterns", `https://.*\.example\.com`}, + {"allowed_origins_patterns", `^https://.*\.example\.com`}, + {"allowed_origins_patterns", `^https?://.*$`}, + {"allowed_origins_patterns", `^.*$`}, + } + for i, b := range blocks { + for _, tc := range accepted { + good := goodOrigin + if tc.key == "allowed_origins_patterns" { + good = goodPattern + } + if err := load(t, i, entry(b.indent, tc.key, good, tc.value, true)); err != nil { + t.Errorf("%s %s %q with credentials: %v", b.name, tc.key, tc.value, err) + } + } + for _, tc := range refused { + good := goodOrigin + if tc.key == "allowed_origins_patterns" { + good = goodPattern + } + want := b.prefix + "." + tc.key + "[1]" + err := load(t, i, entry(b.indent, tc.key, good, tc.value, true)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s %q with credentials: error = %v, want one naming %s", b.name, tc.value, err, want) + } + if err := load(t, i, entry(b.indent, tc.key, good, tc.value, false)); err != nil { + t.Errorf("%s %q without credentials: %v", b.name, tc.value, err) + } + } + } +} + +func TestCORSAnchoredPatternNoSuffixMatch(t *testing.T) { + plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { + r.Get("/x/me", okHandler, "cors:spa") + }} + app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + `cors_profiles: + spa: + allowed_origins_patterns: ['^https://[a-z]+\.example\.com$'] + allowed_methods: ["GET"] + supports_credentials: true +`}) + h, err := Assemble(app, []party.Plugin{plugin}) + if err != nil { + t.Fatal(err) + } + rec := serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com", false)) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" || rec.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("allowed origin: Allow-Origin %q Allow-Credentials %q", got, rec.Header().Get("Access-Control-Allow-Credentials")) + } + rec = serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com.attacker.net", false)) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("suffixed origin: Allow-Origin = %q, want none", got) + } + if rec.Header().Get("Access-Control-Allow-Credentials") != "" { + t.Fatal("suffixed origin: Allow-Credentials set") + } + if !varyHas(rec.Header(), "Origin") { + t.Fatalf("suffixed origin: Vary = %q, want Origin", rec.Header().Values("Vary")) + } +}