fix(19.3-06): production refuses an inferred insecure token cookie (WR-05)

- LoadCookieConfig refuses a Secure that resolves to false in production,
  also when inferred from an http or unset app.url, naming
  auth.cookie.secure and app.url
This commit is contained in:
Jakub Zych
2026-10-15 22:26:43 +02:00
parent e32940d90f
commit 4dae8331c5
4 changed files with 22 additions and 13 deletions

View File

@@ -75,10 +75,10 @@ This fragment belongs in `config/auth.yaml`. With the block unset the cookie is
| `auth.cookie.name` | `token` | Cookie name. |
| `auth.cookie.domain` | empty (host-only) | A parent domain shares the cookie with its subdomains. |
| `auth.cookie.path` | `/` | Cookie path. |
| `auth.cookie.secure` | true when `app.url` is https | `false` is refused in the production environment. |
| `auth.cookie.secure` | true when `app.url` is https | In the production environment a value that resolves to false is refused, whether `false` is set or inferred from an http or unset `app.url`. |
| `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. |
An invalid domain, `same_site: none` without Secure or `secure: false` in production makes `bouncer.LoadCookieConfig` return an error naming the key; return it from your plugin's boot so the application does not start with a cookie browsers would drop.
An invalid domain, `same_site: none` without Secure, or a cookie that is not Secure in production makes `bouncer.LoadCookieConfig` return an error naming the key. In production that covers `secure: false` and an unset `auth.cookie.secure` with an http or missing `app.url`; that error names `auth.cookie.secure` and `app.url`, and you fix it by setting `app.url` to the public https URL or `auth.cookie.secure: true`. Return the error from your plugin's boot so the application does not start with a cookie browsers would drop or send in clear.
`bouncer.CookieConfig.TokenCookie` builds the cookie for a token and a lifetime. Pass the guard's refresh window (`refresh_ttl`, 20160 minutes by default), not its access `ttl`: the access token inside expires after `ttl`, the frontend's 401 handling then calls the refresh endpoint, and that endpoint needs the cookie to still be there. A cookie that died with the token would force a new sign-in every hour. `bouncer.CookieConfig.ForgetTokenCookie` builds the matching cookie that deletes it; it carries the same name, path and domain, or the browser would keep the original.

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 `secure: false` in the production environment; `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. `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).
@@ -191,7 +191,7 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se
| `auth.cookie.name` | `token` | Cookie name; must be a valid cookie name. |
| `auth.cookie.domain` | empty (host-only) | Cookie domain. Set a parent domain such as `example.com` to share the cookie between `api.example.com` and `www.example.com`; every subdomain then receives it. An invalid domain fails boot. |
| `auth.cookie.path` | `/` | Cookie path; must start with `/`. |
| `auth.cookie.secure` | true when `app.url` is https | Sends the cookie over https only. `false` is refused in the production environment. |
| `auth.cookie.secure` | true when `app.url` is https | Sends the cookie over https only. In the production environment a value that resolves to false is refused, whether `false` is set or inferred from an http or unset `app.url`. |
| `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. |
Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOKIE__DOMAIN`.

View File

@@ -52,8 +52,9 @@ type CookieConfigSource interface {
// be a valid cookie domain, so it is never dropped silently when the
// cookie is written.
// - auth.cookie.same_site: lax (default), strict or none, case-insensitive.
// - auth.cookie.secure: unset means true exactly when app.url is https; an
// explicit false is refused in the production environment.
// - auth.cookie.secure: unset means true exactly when app.url is https. In
// the production environment a Secure that resolves to false is refused,
// whether set explicitly or inferred from an http or unset app.url.
//
// same_site none without Secure is refused, as browsers drop such cookies.
// Every error names the key.
@@ -101,8 +102,11 @@ func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error) {
if err != nil {
return cfg, err
}
if explicit && !secure && strings.EqualFold(strings.TrimSpace(src.Environment()), "production") {
return cfg, errors.New("bouncer: auth.cookie.secure: false is not allowed in the production environment")
if !secure && strings.EqualFold(strings.TrimSpace(src.Environment()), "production") {
if explicit {
return cfg, errors.New("bouncer: auth.cookie.secure: false is not allowed in the production environment")
}
return cfg, errors.New("bouncer: auth.cookie.secure resolves to false in the production environment: it is unset and app.url is not an https URL; set app.url to an https URL or auth.cookie.secure: true")
}
cfg.Secure = secure
if cfg.SameSite == http.SameSiteNoneMode && !cfg.Secure {

View File

@@ -39,16 +39,19 @@ func TestLoadCookieConfigDefaults(t *testing.T) {
t.Fatalf("nil source = %+v", cfg)
}
// An http or unset app.url infers Secure false outside production only;
// production refuses it (TestLoadCookieConfigErrors).
for _, tc := range []struct {
url string
env string
secure bool
}{
{"https://app.example.com", true},
{"HTTPS://app.example.com", true},
{"http://localhost:8000", false},
{"", false},
{"https://app.example.com", "production", true},
{"HTTPS://app.example.com", "production", true},
{"http://localhost:8000", "development", false},
{"", "development", false},
} {
cfg, err := LoadCookieConfig(cookieSource{values: map[string]any{"app.url": tc.url}, env: "production"})
cfg, err := LoadCookieConfig(cookieSource{values: map[string]any{"app.url": tc.url}, env: tc.env})
if err != nil {
t.Fatalf("app.url %q: %v", tc.url, err)
}
@@ -104,6 +107,8 @@ func TestLoadCookieConfigErrors(t *testing.T) {
{"same_site none on http app", map[string]any{"auth.cookie.same_site": "none", "app.url": "http://localhost"}, "", "auth.cookie.same_site"},
{"secure false in production", map[string]any{"auth.cookie.secure": false}, "production", "auth.cookie.secure"},
{"secure string false in production", map[string]any{"auth.cookie.secure": "false"}, "Production", "auth.cookie.secure"},
{"secure inferred from http app.url in production", map[string]any{"app.url": "http://api.example.com"}, "production", "app.url"},
{"secure inferred from unset app.url in production", map[string]any{}, "production", "auth.cookie.secure resolves to false"},
{"secure not a bool", map[string]any{"auth.cookie.secure": "maybe"}, "", "auth.cookie.secure"},
{"secure wrong type", map[string]any{"auth.cookie.secure": 1}, "", "auth.cookie.secure"},
{"unknown same_site", map[string]any{"auth.cookie.same_site": "relaxed"}, "", "auth.cookie.same_site"},