diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index 14f456093..59dd56f91 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -56,6 +56,7 @@ var ( forcedLanguage = flag.String("forced-language", "", "if set, this language is being used instead of the one from the request's Accept-Language header") hs512Secret = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set") cookieSecure = flag.Bool("cookie-secure", true, "if true, sets the secure flag on Anubis cookies") + cookieSameSite = flag.String("cookie-same-site", "None", "sets the same site option on Anubis cookies, will auto-downgrade None to Lax if cookie-secure is false. Valid values are None, Lax, Strict, and Default.") ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned") ed25519PrivateKeyHexFile = flag.String("ed25519-private-key-hex-file", "", "file name containing value for ed25519-private-key-hex") metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to") @@ -143,6 +144,22 @@ func parseBindNetFromAddr(address string) (string, string) { return "", address } +func parseSameSite(s string) (http.SameSite) { + switch strings.ToLower(s) { + case "none": + return http.SameSiteNoneMode + case "lax": + return http.SameSiteLaxMode + case "strict": + return http.SameSiteStrictMode + case "default": + return http.SameSiteDefaultMode + default: + log.Fatalf("invalid cookie same-site mode: %s, valid values are None, Lax, Strict, and Default", s) + } + return http.SameSiteDefaultMode +} + func setupListener(network string, address string) (net.Listener, string) { formattedAddress := "" @@ -432,6 +449,7 @@ func main() { WebmasterEmail: *webmasterEmail, OpenGraph: policy.OpenGraph, CookieSecure: *cookieSecure, + CookieSameSite: parseSameSite(*cookieSameSite), PublicUrl: *publicUrl, JWTRestrictionHeader: *jwtRestrictionHeader, DifficultyInJWT: *difficultyInJWT, diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index a5be9d55e..c5a0da837 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +- Add `COOKIE_SAME_SITE_MODE` to force anubis cookies SameSite value, and downgrade automatically from `None` to `Lax` if cookie is insecure. - Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)). - Fix lock convoy problem in bbolt by implementing the actor pattern ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)). - Document missing environment variables in installation guide: `SLOG_LEVEL`, `COOKIE_PREFIX`, `FORCED_LANGUAGE`, and `TARGET_DISABLE_KEEPALIVE` ([#1086](https://github.com/TecharoHQ/anubis/pull/1086)). diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx index efb0fce3e..b4e0caa61 100644 --- a/docs/docs/admin/installation.mdx +++ b/docs/docs/admin/installation.mdx @@ -69,6 +69,7 @@ Anubis uses these environment variables for configuration: | `COOKIE_PARTITIONED` | `false` | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe. | | `COOKIE_PREFIX` | `anubis-cookie` | The prefix used for browser cookies created by Anubis. Useful for customization or avoiding conflicts with other applications. | | `COOKIE_SECURE` | `true` | If set to `true`, enables the [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be transmitted over HTTPS. If Anubis is used in an unsecure context (plain HTTP), this will be need to be set to false | +| `COOKIE_SAME_SITE` | `None` | Controls the cookie’s [`SameSite` attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value). Allowed: `None`, `Lax`, `Strict`, `Default`. `None` permits cross-site use but modern browsers require it to be **Secure**—so if `COOKIE_SECURE=false` or you serve over plain HTTP, use `Lax` (recommended) or `Strict` or the cookie will be rejected. `Default` uses the Go runtime’s `SameSiteDefaultMode`. `None` will be downgraded to `Lax` automatically if cookie is set NOT to be secure. | | `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. | | `DIFFICULTY_IN_JWT` | `false` | If set to `true`, adds the `difficulty` field into JWT claims, which indicates the difficulty the token has been generated. This may be useful for statistics and debugging. | | `ED25519_PRIVATE_KEY_HEX` | unset | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. See below for details. | diff --git a/lib/anubis_test.go b/lib/anubis_test.go index 0f6ef7f5c..133c56d0b 100644 --- a/lib/anubis_test.go +++ b/lib/anubis_test.go @@ -299,6 +299,7 @@ func TestCookieSettings(t *testing.T) { CookieDomain: "127.0.0.1", CookiePartitioned: true, CookieSecure: true, + CookieSameSite: http.SameSiteNoneMode, CookieExpiration: anubis.CookieDefaultExpirationTime, }) @@ -339,6 +340,65 @@ func TestCookieSettings(t *testing.T) { if ckie.Secure != srv.opts.CookieSecure { t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure) } + if ckie.SameSite != srv.opts.CookieSameSite { + t.Errorf("wanted same site option %v, got: %v", srv.opts.CookieSameSite, ckie.SameSite) + } +} + +func TestCookieSettingsSameSiteNoneModeDowngradedToLaxWhenUnsecure(t *testing.T) { + pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0) + + srv := spawnAnubis(t, Options{ + Next: http.NewServeMux(), + Policy: pol, + + CookieDomain: "127.0.0.1", + CookiePartitioned: true, + CookieSecure: false, + CookieSameSite: http.SameSiteNoneMode, + CookieExpiration: anubis.CookieDefaultExpirationTime, + }) + + ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) + defer ts.Close() + + cli := httpClient(t) + chall := makeChallenge(t, ts, cli) + + resp := handleChallengeZeroDifficulty(t, ts, cli, chall) + + if resp.StatusCode != http.StatusFound { + resp.Write(os.Stderr) + t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode) + } + + var ckie *http.Cookie + for _, cookie := range resp.Cookies() { + t.Logf("%#v", cookie) + if cookie.Name == anubis.CookieName { + ckie = cookie + break + } + } + if ckie == nil { + t.Errorf("Cookie %q not found", anubis.CookieName) + return + } + + if ckie.Domain != "127.0.0.1" { + t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain) + } + + if ckie.Partitioned != srv.opts.CookiePartitioned { + t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned) + } + + if ckie.Secure != srv.opts.CookieSecure { + t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure) + } + if ckie.SameSite != http.SameSiteLaxMode { + t.Errorf("wanted same site Lax option %v, got: %v", http.SameSiteLaxMode, ckie.SameSite) + } } func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) { diff --git a/lib/config.go b/lib/config.go index c9437e67a..7ab312d1c 100644 --- a/lib/config.go +++ b/lib/config.go @@ -43,6 +43,7 @@ type Options struct { OpenGraph config.OpenGraph ServeRobotsTXT bool CookieSecure bool + CookieSameSite http.SameSite Logger *slog.Logger PublicUrl string JWTRestrictionHeader string diff --git a/lib/http.go b/lib/http.go index 611070794..7209d582e 100644 --- a/lib/http.go +++ b/lib/http.go @@ -56,6 +56,8 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) { var domain = s.opts.CookieDomain var name = anubis.CookieName var path = "/" + var sameSite = s.opts.CookieSameSite + if cookieOpts.Name != "" { name = cookieOpts.Name } @@ -72,11 +74,15 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) { cookieOpts.Expiry = s.opts.CookieExpiration } + if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure { + sameSite = http.SameSiteLaxMode + } + http.SetCookie(w, &http.Cookie{ Name: name, Value: cookieOpts.Value, Expires: time.Now().Add(cookieOpts.Expiry), - SameSite: http.SameSiteNoneMode, + SameSite: sameSite, Domain: domain, Secure: s.opts.CookieSecure, Partitioned: s.opts.CookiePartitioned, @@ -88,6 +94,8 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) { var domain = s.opts.CookieDomain var name = anubis.CookieName var path = "/" + var sameSite = s.opts.CookieSameSite + if cookieOpts.Name != "" { name = cookieOpts.Name } @@ -99,13 +107,16 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) { domain = etld } } + if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure { + sameSite = http.SameSiteLaxMode + } http.SetCookie(w, &http.Cookie{ Name: name, Value: "", MaxAge: -1, Expires: time.Now().Add(-1 * time.Minute), - SameSite: http.SameSiteNoneMode, + SameSite: sameSite, Partitioned: s.opts.CookiePartitioned, Domain: domain, Secure: s.opts.CookieSecure,