fix(19.3-06): zero CookieConfig writes SameSite=Lax and Secure (WR-09)

- withDefaults gives the zero config Secure and every unset SameSite Lax;
  a config with any field set keeps its Secure value
This commit is contained in:
Jakub Zych
2026-10-15 22:27:31 +02:00
parent 4dae8331c5
commit 85d8b6a85a
4 changed files with 30 additions and 8 deletions

View File

@@ -31,7 +31,7 @@ Use cases:
- Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record).
- Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`).
- Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names.
- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and, in the production environment, a Secure that resolves to false (set to `false`, or inferred from an http or unset `app.url`); `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope.
- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and, in the production environment, a Secure that resolves to false (set to `false`, or inferred from an http or unset `app.url`); `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. A hand-built config with `SameSite` unset writes `SameSite=Lax`, and the zero `bouncer.CookieConfig` writes `token` on `/` with `HttpOnly`, `SameSite=Lax` and `Secure`. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope.
- Cloudflare Turnstile: `bouncer.Turnstile.Verify` checks a token against siteverify with a 5 second timeout, in PHP's open mode (an empty secret passes) or closed mode (an empty secret is `bouncer.TurnstileUnavailable` in production); `bouncer.Turnstile.Middleware` reads the token from the query, a JSON body or a form body, restores the body and writes refusals through a `bouncer.TurnstileWriter`. surf registers it as `turnstile:open` and `turnstile:closed`.
- Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller).
@@ -156,7 +156,7 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se
| `bouncer.BlacklistStore` | Revoked-jti store with a grace window and sweeping. |
| `bouncer.NewMemoryBlacklist` | In-process blacklist for tests. |
| `bouncer.NewPostgresBlacklist` | Blacklist over a `*sql.DB` and a table name. |
| `bouncer.CookieConfig` | Token cookie attributes: `Name`, `Domain`, `Path`, `Secure`, `SameSite`. |
| `bouncer.CookieConfig` | Token cookie attributes: `Name`, `Domain`, `Path`, `Secure`, `SameSite`. An unset `SameSite` is Lax; the zero config is also `Secure`, while a config with any field set keeps its `Secure` value. |
| `bouncer.CookieConfigSource` | The config methods `bouncer.LoadCookieConfig` reads (`Lookup`, `String`, `Environment`); `*compass.Config` satisfies it. |
| `bouncer.LoadCookieConfig` | Reads and validates `auth.cookie.*`; every error names the key. |
| `bouncer.DefaultCookieName` | `token`, the cookie name when `auth.cookie.name` is unset. |

View File

@@ -29,10 +29,12 @@ type CookieConfig struct {
Domain string
// Path is the cookie path (auth.cookie.path, default "/").
Path string
// Secure sends the cookie over https only.
// Secure sends the cookie over https only. It has no default except for
// the zero CookieConfig, which writes a Secure cookie; LoadCookieConfig
// derives it from app.url unless auth.cookie.secure is set.
Secure bool
// SameSite is http.SameSiteLaxMode unless auth.cookie.same_site says
// otherwise.
// SameSite is http.SameSiteLaxMode when unset (also on a hand-built
// config) and otherwise what auth.cookie.same_site says.
SameSite http.SameSite
}
@@ -263,9 +265,18 @@ func CookieLogoutHandler(opts CookieLogoutOptions) (http.Handler, error) {
}), nil
}
// withDefaults fills an empty Name and Path, so a zero CookieConfig writes
// the default token cookie on "/".
// withDefaults fills an empty Name and Path and an unset SameSite (Lax). The
// zero CookieConfig also gets Secure, so it writes the default token cookie
// on "/" with HttpOnly, SameSite=Lax and Secure; a config with any field set
// keeps its Secure value (LoadCookieConfig derives it from app.url).
// TokenCookie, ForgetTokenCookie and both handlers go through it.
func (c CookieConfig) withDefaults() CookieConfig {
if c == (CookieConfig{}) {
c.Secure = true
}
if c.SameSite == 0 {
c.SameSite = http.SameSiteLaxMode
}
if strings.TrimSpace(c.Name) == "" {
c.Name = DefaultCookieName
}

View File

@@ -147,11 +147,20 @@ func TestTokenCookieAttributes(t *testing.T) {
}
}
// A zero config writes the default cookie on "/" and host-only.
// A zero config writes the default cookie on "/", host-only, with
// SameSite=Lax and Secure.
z := CookieConfig{}.TokenCookie("v", time.Minute)
if z.Name != DefaultCookieName || z.Path != "/" || z.Domain != "" || !z.HttpOnly {
t.Fatalf("zero config cookie = %+v", z)
}
if z.SameSite != http.SameSiteLaxMode || !z.Secure {
t.Fatalf("zero config cookie SameSite %v Secure %t, want Lax and Secure", z.SameSite, z.Secure)
}
// A partial config gets SameSite=Lax and keeps its Secure value.
p := CookieConfig{Name: "sid"}.TokenCookie("v", time.Minute)
if p.SameSite != http.SameSiteLaxMode || p.Secure {
t.Fatalf("partial config cookie SameSite %v Secure %t, want Lax and not Secure", p.SameSite, p.Secure)
}
if strings.Contains(z.String(), "Domain=") {
t.Fatalf("host-only cookie carries a domain: %s", z.String())
}