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:
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user