Repository: TecharoHQ/anubis

Issue:
Cookie: domain-wide cookies
As a followup to PR #70 - configurable secrets, please provide more control over the cookie parameters. This would permit more use cases:

- Cookie domain: Shared anubis validation over an entire domain, without clients having to complete challenges per-site, e.g. gitweb.gentoo.org, bugs.gentoo.org, forums.gentoo.org, as well as dynamic subdomains `https://#{bugno}.bugs.gentoo.org/attachment.cgi?id=#{attachmentno}`
- Cookie name: Ease of testing changes in Anubis without invalidating other cookies
- [Partitioned flag](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy_sandbox/Partitioned_cookies): safely protect content in iframes

Hints from issue comments:
@AlexanderYastrebov: Note that it should prevent cookie reuse across domains e.g. cookie for foo.example.org should not be valid for bar.example.org (unless both cookies issued for parent domain example.org). I think this could be achieved in two ways - encode allowed domain into the claims (like here https://github.com/zalando/skipper/pull/2213) OR derive signing key from domain via KDF.  

Solution patch:
diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go
index 551ed61..024b972 100644
--- a/cmd/anubis/main.go
+++ b/cmd/anubis/main.go
@@ -33,6 +33,9 @@ import (
 var (
 	bind                 = flag.String("bind", ":8923", "network address to bind HTTP to")
 	bindNetwork          = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
+	cookieDomain         = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
+	cookieName           = flag.String("cookie-name", anubis.CookieName, "the name of the cookie that Anubis stores challenge pass records in")
+	cookiePartitioned    = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
 	challengeDifficulty  = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
 	ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
 	metricsBind          = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index aad5375..1193ca0 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fixed bot check to only apply if address range matches
 - Fix default difficulty setting that was broken in a refactor
 - Linting fixes
+- Extra cookie options can be set such as the name, domain, and partitioned flag [#73](https://github.com/TecharoHQ/anubis/issues/73)
 - Make dark mode diff lines readable in the documentation
 
 ## v1.14.2
diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx
index 3f5e904..1db648e 100644
--- a/docs/docs/admin/installation.mdx
+++ b/docs/docs/admin/installation.mdx
@@ -41,18 +41,21 @@ Anubis has very minimal system requirements. I suspect that 128Mi of ram may be
 
 Anubis uses these environment variables for configuration:
 
-| Environment Variable      | Default value           | Explanation                                                                                                                                                                                                                                                                              |
-| :------------------------ | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `BIND`                    | `:8923`                 | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock`                                                                                                                                                                                  |
-| `BIND_NETWORK`            | `tcp`                   | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports.                                                                                                                                               |
-| `DIFFICULTY`              | `5`                     | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses.                                                                                                                                                                                   |
-| `ED25519_PRIVATE_KEY_HEX` |                         | 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. See below for details.                                                                                               |
-| `METRICS_BIND`            | `:9090`                 | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information.                                                                                                                                                                                           |
-| `METRICS_BIND_NETWORK`    | `tcp`                   | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information.                                                                                                                                                                                   |
-| `SOCKET_MODE`             | `0770`                  | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets.                                                                                                                                                |
-| `POLICY_FNAME`            | unset                   | The file containing [bot policy configuration](./policies.md). See the bot policy documentation for more details. If unset, the default bot policy configuration is used.                                                                                                                |
-| `SERVE_ROBOTS_TXT`        | `false`                 | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. |
-| `TARGET`                  | `http://localhost:3923` | The URL of the service that Anubis should forward valid requests to. Supports Unix domain sockets, set this to a URI like so: `unix:///path/to/socket.sock`.                                                                                                                             |
+| Environment Variable      | Default value                      | Explanation                                                                                                                                                                                                                                                                              |
+| :------------------------ | :--------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `BIND`                    | `:8923`                            | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock`                                                                                                                                                                                  |
+| `BIND_NETWORK`            | `tcp`                              | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports.                                                                                                                                               |
+| `COOKIE_DOMAIN`           | unset                              | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See [here](https://stackoverflow.com/a/1063760) for more information.               |
+| `COOKIE_NAME`             | `within.website-x-cmd-anubis-auth` | The cookie that Anubis uses to determine if users have passed a challenge. This should not be changed without a good reason.                                                                                                                                                             |
+| `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.                                                           |
+| `DIFFICULTY`              | `5`                                | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses.                                                                                                                                                                                   |
+| `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. See below for details.                                                                                               |
+| `METRICS_BIND`            | `:9090`                            | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information.                                                                                                                                                                                           |
+| `METRICS_BIND_NETWORK`    | `tcp`                              | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information.                                                                                                                                                                                   |
+| `SOCKET_MODE`             | `0770`                             | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets.                                                                                                                                                |
+| `POLICY_FNAME`            | unset                              | The file containing [bot policy configuration](./policies.md). See the bot policy documentation for more details. If unset, the default bot policy configuration is used.                                                                                                                |
+| `SERVE_ROBOTS_TXT`        | `false`                            | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. |
+| `TARGET`                  | `http://localhost:3923`            | The URL of the service that Anubis should forward valid requests to. Supports Unix domain sockets, set this to a URI like so: `unix:///path/to/socket.sock`.                                                                                                                             |
 
 ### Key generation
 
diff --git a/lib/anubis.go b/lib/anubis.go
index 6e40f95..6b5a1e3 100644
--- a/lib/anubis.go
+++ b/lib/anubis.go
@@ -67,6 +67,10 @@ type Options struct {
 	Policy         *policy.ParsedConfig
 	ServeRobotsTXT bool
 	PrivateKey     ed25519.PrivateKey
+
+	CookieDomain      string
+	CookieName        string
+	CookiePartitioned bool
 }
 
 func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
@@ -108,6 +112,7 @@ func New(opts Options) (*Server, error) {
 		priv:       opts.PrivateKey,
 		pub:        opts.PrivateKey.Public().(ed25519.PublicKey),
 		policy:     opts.Policy,
+		opts:       opts,
 		DNSBLCache: decaymap.New[string, dnsbl.DroneBLResponse](),
 	}
 
@@ -145,6 +150,7 @@ type Server struct {
 	priv                ed25519.PrivateKey
 	pub                 ed25519.PublicKey
 	policy              *policy.ParsedConfig
+	opts                Options
 	DNSBLCache          *decaymap.Impl[string, dnsbl.DroneBLResponse]
 	ChallengeDifficulty int
 }
@@ -217,7 +223,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 		s.next.ServeHTTP(w, r)
 		return
 	case config.RuleDeny:
-		ClearCookie(w)
+		s.ClearCookie(w)
 		lg.Info("explicit deny")
 		if rule == nil {
 			lg.Error("rule is nil, cannot calculate checksum")
@@ -236,29 +242,29 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 	case config.RuleChallenge:
 		lg.Debug("challenge requested")
 	default:
-		ClearCookie(w)
+		s.ClearCookie(w)
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
 		return
 	}
 
-	ckie, err := r.Cookie(anubis.CookieName)
+	ckie, err := r.Cookie(s.opts.CookieName)
 	if err != nil {
 		lg.Debug("cookie not found", "path", r.URL.Path)
-		ClearCookie(w)
+		s.ClearCookie(w)
 		s.RenderIndex(w, r)
 		return
 	}
 
 	if err := ckie.Valid(); err != nil {
 		lg.Debug("cookie is invalid", "err", err)
-		ClearCookie(w)
+		s.ClearCookie(w)
 		s.RenderIndex(w, r)
 		return
 	}
 
 	if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
 		lg.Debug("cookie expired", "path", r.URL.Path)
-		ClearCookie(w)
+		s.ClearCookie(w)
 		s.RenderIndex(w, r)
 		return
 	}
@@ -269,7 +275,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil || !token.Valid {
 		lg.Debug("invalid token", "path", r.URL.Path, "err", err)
-		ClearCookie(w)
+		s.ClearCookie(w)
 		s.RenderIndex(w, r)
 		return
 	}
@@ -284,7 +290,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 	claims, ok := token.Claims.(jwt.MapClaims)
 	if !ok {
 		lg.Debug("invalid token claims type", "path", r.URL.Path)
-		ClearCookie(w)
+		s.ClearCookie(w)
 		s.RenderIndex(w, r)
 		return
 	}
@@ -292,7 +298,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 
 	if claims["challenge"] != challenge {
 		lg.Debug("invalid challenge", "path", r.URL.Path)
-		ClearCookie(w)
+		s.ClearCookie(w)
 		s.RenderIndex(w, r)
 		return
 	}
@@ -309,7 +315,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
 	if subtle.ConstantTimeCompare([]byte(claims["response"].(string)), []byte(calculated)) != 1 {
 		lg.Debug("invalid response", "path", r.URL.Path)
 		failedValidations.Inc()
-		ClearCookie(w)
+		s.ClearCookie(w)
 		s.RenderIndex(w, r)
 		return
 	}
@@ -372,7 +378,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 
 	nonceStr := r.FormValue("nonce")
 	if nonceStr == "" {
-		ClearCookie(w)
+		s.ClearCookie(w)
 		lg.Debug("no nonce")
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
 		return
@@ -380,7 +386,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 
 	elapsedTimeStr := r.FormValue("elapsedTime")
 	if elapsedTimeStr == "" {
-		ClearCookie(w)
+		s.ClearCookie(w)
 		lg.Debug("no elapsedTime")
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
 		return
@@ -388,7 +394,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 
 	elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
 	if err != nil {
-		ClearCookie(w)
+		s.ClearCookie(w)
 		lg.Debug("elapsedTime doesn't parse", "err", err)
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
 		return
@@ -404,7 +410,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 
 	nonce, err := strconv.Atoi(nonceStr)
 	if err != nil {
-		ClearCookie(w)
+		s.ClearCookie(w)
 		lg.Debug("nonce doesn't parse", "err", err)
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
 		return
@@ -414,7 +420,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 	calculated := internal.SHA256sum(calcString)
 
 	if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
-		ClearCookie(w)
+		s.ClearCookie(w)
 		lg.Debug("hash does not match", "got", response, "want", calculated)
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
 		failedValidations.Inc()
@@ -423,7 +429,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 
 	// compare the leading zeroes
 	if !strings.HasPrefix(response, strings.Repeat("0", s.ChallengeDifficulty)) {
-		ClearCookie(w)
+		s.ClearCookie(w)
 		lg.Debug("difficulty check failed", "response", response, "difficulty", s.ChallengeDifficulty)
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
 		failedValidations.Inc()
@@ -442,17 +448,18 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 	tokenString, err := token.SignedString(s.priv)
 	if err != nil {
 		lg.Error("failed to sign JWT", "err", err)
-		ClearCookie(w)
+		s.ClearCookie(w)
 		templ.Handler(web.Base("Oh noes!", web.ErrorPage("failed to sign JWT")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
 		return
 	}
 
 	http.SetCookie(w, &http.Cookie{
-		Name:     anubis.CookieName,
-		Value:    tokenString,
-		Expires:  time.Now().Add(24 * 7 * time.Hour),
-		SameSite: http.SameSiteLaxMode,
-		Path:     "/",
+		Name:        s.opts.CookieName,
+		Value:       tokenString,
+		Expires:     time.Now().Add(24 * 7 * time.Hour),
+		Domain:      s.opts.CookieDomain,
+		Partitioned: s.opts.CookiePartitioned,
+		SameSite:    http.SameSiteLaxMode,
 	})
 
 	challengesValidated.Inc()
diff --git a/lib/http.go b/lib/http.go
index 1284523..d7f678f 100644
--- a/lib/http.go
+++ b/lib/http.go
@@ -3,17 +3,17 @@ package lib
 import (
 	"net/http"
 	"time"
-
-	"github.com/TecharoHQ/anubis"
 )
 
-func ClearCookie(w http.ResponseWriter) {
+func (s *Server) ClearCookie(w http.ResponseWriter) {
 	http.SetCookie(w, &http.Cookie{
-		Name:     anubis.CookieName,
-		Value:    "",
-		Expires:  time.Now().Add(-1 * time.Hour),
-		MaxAge:   -1,
-		SameSite: http.SameSiteLaxMode,
+		Name:        s.opts.CookieName,
+		Value:       "",
+		Expires:     time.Now().Add(-1 * time.Hour),
+		MaxAge:      -1,
+		Domain:      s.opts.CookieDomain,
+		Partitioned: s.opts.CookiePartitioned,
+		SameSite:    http.SameSiteLaxMode,
 	})
 }
 



Test patch:
diff --git a/lib/anubis_test.go b/lib/anubis_test.go
index 0498c13..7369b5d 100644
--- a/lib/anubis_test.go
+++ b/lib/anubis_test.go
@@ -1,15 +1,18 @@
 package lib
 
 import (
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 
 	"github.com/TecharoHQ/anubis"
+	"github.com/TecharoHQ/anubis/internal"
+	"github.com/TecharoHQ/anubis/lib/policy"
 )
 
-func spawnAnubis(t *testing.T, h http.Handler) string {
+func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
 	t.Helper()
 
 	policy, err := LoadPoliciesOrDefault("", anubis.DefaultDifficulty)
@@ -17,23 +20,97 @@ func spawnAnubis(t *testing.T, h http.Handler) string {
 		t.Fatal(err)
 	}
 
-	s, err := New(Options{
-		Next:           h,
-		Policy:         policy,
-		ServeRobotsTXT: true,
-	})
+	return policy
+}
+
+func spawnAnubis(t *testing.T, opts Options) *Server {
+	t.Helper()
+
+	s, err := New(opts)
 	if err != nil {
 		t.Fatalf("can't construct libanubis.Server: %v", err)
 	}
 
-	ts := httptest.NewServer(s)
-	t.Log(ts.URL)
+	return s
+}
+
+func TestCookieSettings(t *testing.T) {
+	pol := loadPolicies(t, "")
+	pol.DefaultDifficulty = 0
+
+	srv := spawnAnubis(t, Options{
+		Next:   http.NewServeMux(),
+		Policy: pol,
 
-	t.Cleanup(func() {
-		ts.Close()
+		CookieDomain:      "local.cetacean.club",
+		CookiePartitioned: true,
+		CookieName:        t.Name(),
 	})
 
-	return ts.URL
+	ts := httptest.NewServer(internal.DefaultXRealIP("127.0.0.1", srv))
+	defer ts.Close()
+
+	cli := &http.Client{
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+
+	resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
+	if err != nil {
+		t.Fatalf("can't request challenge: %v", err)
+	}
+	defer resp.Body.Close()
+
+	var chall = struct {
+		Challenge string `json:"challenge"`
+	}{}
+	if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
+		t.Fatalf("can't read challenge response body: %v", err)
+	}
+
+	nonce := 0
+	elapsedTime := 420
+	redir := "/"
+	calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
+	calculated := internal.SHA256sum(calcString)
+
+	req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
+	if err != nil {
+		t.Fatalf("can't make request: %v", err)
+	}
+
+	q := req.URL.Query()
+	q.Set("response", calculated)
+	q.Set("nonce", fmt.Sprint(nonce))
+	q.Set("redir", redir)
+	q.Set("elapsedTime", fmt.Sprint(elapsedTime))
+	req.URL.RawQuery = q.Encode()
+
+	resp, err = cli.Do(req)
+	if err != nil {
+		t.Fatalf("can't do challenge passing")
+	}
+
+	if resp.StatusCode != http.StatusFound {
+		t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
+	}
+
+	found := false
+	for _, cookie := range resp.Cookies() {
+		t.Logf("%#v", cookie)
+		if cookie.Name == t.Name() {
+			found = true
+		}
+
+		if found && cookie.Domain != "local.cetacean.club" {
+			t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", cookie.Domain)
+		}
+	}
+
+	if !found {
+		t.Errorf("Cookie %q not found", t.Name())
+	}
 }
 
 func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {