feat: dynamic cookie domains (#722)

* feat: dynamic cookie domains

Replaces #685

I was having weird testing issues when trying to merge #685, so I
rewrote it from scratch to be a lot more minimal.

* chore: spelling

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso 2025-06-26 08:11:59 -04:00 committed by GitHub
parent 7cf6ac5de6
commit a1b7d2ccda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 137 additions and 37 deletions

View File

@ -75,6 +75,7 @@ duckduckbot
eerror eerror
ellenjoe ellenjoe
enbyware enbyware
etld
everyones everyones
evilbot evilbot
evilsite evilsite
@ -164,6 +165,7 @@ Linting
linuxbrew linuxbrew
LLU LLU
loadbalancer loadbalancer
locahost
lol lol
LOMINSA LOMINSA
maintainership maintainership
@ -210,6 +212,7 @@ privkey
promauto promauto
promhttp promhttp
proofofwork proofofwork
publicsuffix
pwcmd pwcmd
pwuser pwuser
qualys qualys

View File

@ -46,6 +46,7 @@ var (
bindNetwork = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp") bindNetwork = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
challengeDifficulty = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge") challengeDifficulty = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
cookieDomain = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for") cookieDomain = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
cookieDynamicDomain = flag.Bool("cookie-dynamic-domain", false, "if set, automatically set the cookie Domain value based on the request domain")
cookieExpiration = flag.Duration("cookie-expiration-time", anubis.CookieDefaultExpirationTime, "The amount of time the authorization cookie is valid for") cookieExpiration = flag.Duration("cookie-expiration-time", anubis.CookieDefaultExpirationTime, "The amount of time the authorization cookie is valid for")
cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support") cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
hs512Secret = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set") hs512Secret = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set")
@ -240,6 +241,10 @@ func main() {
} }
} }
if *cookieDomain != "" && *cookieDynamicDomain {
log.Fatalf("you can't set COOKIE_DOMAIN and COOKIE_DYNAMIC_DOMAIN at the same time")
}
ctx := context.Background() ctx := context.Background()
// Thoth configuration // Thoth configuration

View File

@ -46,6 +46,7 @@ And some cleanups/refactors were added:
- Add `--strip-base-prefix` flag/envvar to strip the base prefix from request paths when forwarding to target servers - Add `--strip-base-prefix` flag/envvar to strip the base prefix from request paths when forwarding to target servers
- Fix an off-by-one in the default threshold config - Fix an off-by-one in the default threshold config
- Add functionality for HS512 JWT algorithm - Add functionality for HS512 JWT algorithm
- Add support for dynamic cookie domains with the `--cookie-dynamic-domain`/`COOKIE_DYNAMIC_DOMAIN` flag/envvar
Request weight is one of the biggest ticket features in Anubis. This enables Anubis to be much closer to a Web Application Firewall and when combined with custom thresholds allows administrators to have Anubis take advanced reactions. For more information about request weight, see [the request weight section](./admin/policies.mdx#request-weight) of the policy file documentation. Request weight is one of the biggest ticket features in Anubis. This enables Anubis to be much closer to a Web Application Firewall and when combined with custom thresholds allows administrators to have Anubis take advanced reactions. For more information about request weight, see [the request weight section](./admin/policies.mdx#request-weight) of the policy file documentation.

View File

@ -64,6 +64,7 @@ Anubis uses these environment variables for configuration:
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` | | `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. | | `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 this [stackoverflow explanation of cookies](https://stackoverflow.com/a/1063760) for more information.<br/><br/>Note that unlike `REDIRECT_DOMAINS`, you should never include a port number in this variable. | | `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 this [stackoverflow explanation of cookies](https://stackoverflow.com/a/1063760) for more information.<br/><br/>Note that unlike `REDIRECT_DOMAINS`, you should never include a port number in this variable. |
| `COOKIE_DYNAMIC_DOMAIN` | false | If set to true, automatically set cookie domain fields based on the hostname of the request. EG: if you are making a request to `anubis.techaro.lol`, the Anubis cookie will be valid for any subdomain of `techaro.lol`. |
| `COOKIE_EXPIRATION_TIME` | `168h` | The amount of time the authorization cookie is valid for. | | `COOKIE_EXPIRATION_TIME` | `168h` | The amount of time the authorization cookie is valid for. |
| `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_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` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. | | `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |

View File

@ -148,21 +148,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
ckie, err := r.Cookie(s.cookieName) ckie, err := r.Cookie(s.cookieName)
if err != nil { if err != nil {
lg.Debug("cookie not found", "path", r.URL.Path) lg.Debug("cookie not found", "path", r.URL.Path)
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, rule, httpStatusOnly)
return return
} }
if err := ckie.Valid(); err != nil { if err := ckie.Valid(); err != nil {
lg.Debug("cookie is invalid", "err", err) lg.Debug("cookie is invalid", "err", err)
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, rule, httpStatusOnly)
return return
} }
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() { if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
lg.Debug("cookie expired", "path", r.URL.Path) lg.Debug("cookie expired", "path", r.URL.Path)
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, rule, httpStatusOnly)
return return
} }
@ -171,7 +171,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if err != nil || !token.Valid { if err != nil || !token.Valid {
lg.Debug("invalid token", "path", r.URL.Path, "err", err) lg.Debug("invalid token", "path", r.URL.Path, "err", err)
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, rule, httpStatusOnly)
return return
} }
@ -179,7 +179,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
lg.Debug("invalid token claims type", "path", r.URL.Path) lg.Debug("invalid token claims type", "path", r.URL.Path)
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, rule, httpStatusOnly)
return return
} }
@ -187,14 +187,14 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
policyRule, ok := claims["policyRule"].(string) policyRule, ok := claims["policyRule"].(string)
if !ok { if !ok {
lg.Debug("policyRule claim is not a string") lg.Debug("policyRule claim is not a string")
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, rule, httpStatusOnly)
return return
} }
if policyRule != rule.Hash() { if policyRule != rule.Hash() {
lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name) lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name)
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.RenderIndex(w, r, rule, httpStatusOnly) s.RenderIndex(w, r, rule, httpStatusOnly)
return return
} }
@ -216,7 +216,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
s.ServeHTTPNext(w, r) s.ServeHTTPNext(w, r)
return true return true
case config.RuleDeny: case config.RuleDeny:
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
lg.Info("explicit deny") lg.Info("explicit deny")
if rule == nil { if rule == nil {
lg.Error("rule is nil, cannot calculate checksum") lg.Error("rule is nil, cannot calculate checksum")
@ -235,7 +235,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
s.RenderBench(w, r) s.RenderBench(w, r)
return true return true
default: default:
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule) slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"") s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"")
return true return true
@ -302,7 +302,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
lg = lg.With("check_result", cr) lg = lg.With("check_result", cr)
chal := s.challengeFor(r, rule.Challenge.Difficulty) chal := s.challengeFor(r, rule.Challenge.Difficulty)
s.SetCookie(w, anubis.TestCookieName, chal, "/") s.SetCookie(w, anubis.TestCookieName, chal, "/", r.Host)
err = encoder.Encode(struct { err = encoder.Encode(struct {
Rules *config.ChallengeRules `json:"rules"` Rules *config.ChallengeRules `json:"rules"`
@ -330,14 +330,14 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
} }
if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) { if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) {
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.ClearCookie(w, anubis.TestCookieName, "/") s.ClearCookie(w, anubis.TestCookieName, "/", r.Host)
lg.Warn("user has cookies disabled, this is not an anubis bug") lg.Warn("user has cookies disabled, this is not an anubis bug")
s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain") s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain")
return return
} }
s.ClearCookie(w, anubis.TestCookieName, "/") s.ClearCookie(w, anubis.TestCookieName, "/", r.Host)
redir := r.FormValue("redir") redir := r.FormValue("redir")
redirURL, err := url.ParseRequestURI(redir) redirURL, err := url.ParseRequestURI(redir)
@ -379,7 +379,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if err := impl.Validate(r, lg, rule, challengeStr); err != nil { if err := impl.Validate(r, lg, rule, challengeStr); err != nil {
failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc() failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
var cerr *challenge.Error var cerr *challenge.Error
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
lg.Debug("challenge validate call failed", "err", err) lg.Debug("challenge validate call failed", "err", err)
switch { switch {
@ -402,12 +402,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
}) })
if err != nil { if err != nil {
lg.Error("failed to sign JWT", "err", err) lg.Error("failed to sign JWT", "err", err)
s.ClearCookie(w, s.cookieName, cookiePath) s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.respondWithError(w, r, "failed to sign JWT") s.respondWithError(w, r, "failed to sign JWT")
return return
} }
s.SetCookie(w, s.cookieName, tokenString, cookiePath) s.SetCookie(w, s.cookieName, tokenString, cookiePath, r.Host)
challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc() challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
lg.Debug("challenge passed, redirecting to app") lg.Debug("challenge passed, redirecting to app")

View File

@ -31,17 +31,18 @@ type Options struct {
Next http.Handler Next http.Handler
Policy *policy.ParsedConfig Policy *policy.ParsedConfig
Target string Target string
CookieDynamicDomain bool
CookieDomain string CookieDomain string
CookieExpiration time.Duration
CookieName string CookieName string
CookiePartitioned bool
BasePrefix string BasePrefix string
WebmasterEmail string WebmasterEmail string
RedirectDomains []string RedirectDomains []string
ED25519PrivateKey ed25519.PrivateKey ED25519PrivateKey ed25519.PrivateKey
HS512Secret []byte HS512Secret []byte
CookieExpiration time.Duration
StripBasePrefix bool StripBasePrefix bool
OpenGraph config.OpenGraph OpenGraph config.OpenGraph
CookiePartitioned bool
ServeRobotsTXT bool ServeRobotsTXT bool
} }

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"math/rand" "math/rand"
"net/http" "net/http"
"regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
@ -15,21 +16,40 @@ import (
"github.com/TecharoHQ/anubis/web" "github.com/TecharoHQ/anubis/web"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/net/publicsuffix"
) )
func (s *Server) SetCookie(w http.ResponseWriter, name, value, path string) { var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
func (s *Server) SetCookie(w http.ResponseWriter, name, value, path, host string) {
var domain = s.opts.CookieDomain
if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(host) {
if etld, err := publicsuffix.EffectiveTLDPlusOne(host); err == nil {
domain = etld
name = anubis.WithDomainCookieName + etld
}
}
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: name, Name: name,
Value: value, Value: value,
Expires: time.Now().Add(s.opts.CookieExpiration), Expires: time.Now().Add(s.opts.CookieExpiration),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Domain: s.opts.CookieDomain, Domain: domain,
Partitioned: s.opts.CookiePartitioned, Partitioned: s.opts.CookiePartitioned,
Path: path, Path: path,
}) })
} }
func (s *Server) ClearCookie(w http.ResponseWriter, name, path string) { func (s *Server) ClearCookie(w http.ResponseWriter, name, path, host string) {
var domain = s.opts.CookieDomain
if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(host) {
if etld, err := publicsuffix.EffectiveTLDPlusOne(host); err == nil {
domain = etld
name = anubis.WithDomainCookieName + etld
}
}
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: name, Name: name,
Value: "", Value: "",
@ -37,7 +57,7 @@ func (s *Server) ClearCookie(w http.ResponseWriter, name, path string) {
Expires: time.Now().Add(-1 * time.Minute), Expires: time.Now().Add(-1 * time.Minute),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Partitioned: s.opts.CookiePartitioned, Partitioned: s.opts.CookiePartitioned,
Domain: s.opts.CookieDomain, Domain: domain,
Path: path, Path: path,
}) })
} }

View File

@ -7,11 +7,55 @@ import (
"github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis"
) )
func TestSetCookie(t *testing.T) {
for _, tt := range []struct {
name string
options Options
host string
cookieName string
}{
{
name: "basic",
options: Options{},
host: "",
cookieName: anubis.CookieName,
},
{
name: "domain techaro.lol",
options: Options{CookieDomain: "techaro.lol"},
host: "",
cookieName: anubis.WithDomainCookieName + "techaro.lol",
},
{
name: "dynamic cookie domain",
options: Options{CookieDynamicDomain: true},
host: "techaro.lol",
cookieName: anubis.WithDomainCookieName + "techaro.lol",
},
} {
t.Run(tt.name, func(t *testing.T) {
srv := spawnAnubis(t, tt.options)
rw := httptest.NewRecorder()
srv.SetCookie(rw, srv.cookieName, "test", "/", tt.host)
resp := rw.Result()
cookies := resp.Cookies()
ckie := cookies[0]
if ckie.Name != tt.cookieName {
t.Errorf("wanted cookie named %q, got cookie named %q", tt.cookieName, ckie.Name)
}
})
}
}
func TestClearCookie(t *testing.T) { func TestClearCookie(t *testing.T) {
srv := spawnAnubis(t, Options{}) srv := spawnAnubis(t, Options{})
rw := httptest.NewRecorder() rw := httptest.NewRecorder()
srv.ClearCookie(rw, srv.cookieName, "/") srv.ClearCookie(rw, srv.cookieName, "/", "localhost")
resp := rw.Result() resp := rw.Result()
@ -36,7 +80,7 @@ func TestClearCookieWithDomain(t *testing.T) {
srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"}) srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"})
rw := httptest.NewRecorder() rw := httptest.NewRecorder()
srv.ClearCookie(rw, srv.cookieName, "/") srv.ClearCookie(rw, srv.cookieName, "/", "locahost")
resp := rw.Result() resp := rw.Result()
@ -56,3 +100,28 @@ func TestClearCookieWithDomain(t *testing.T) {
t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge) t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge)
} }
} }
func TestClearCookieWithDynamicDomain(t *testing.T) {
srv := spawnAnubis(t, Options{CookieDynamicDomain: true})
rw := httptest.NewRecorder()
srv.ClearCookie(rw, srv.cookieName, "/", "xeiaso.net")
resp := rw.Result()
cookies := resp.Cookies()
if len(cookies) != 1 {
t.Errorf("wanted 1 cookie, got %d cookies", len(cookies))
}
ckie := cookies[0]
if ckie.Name != anubis.WithDomainCookieName+"xeiaso.net" {
t.Errorf("wanted cookie named %q, got cookie named %q", srv.cookieName, ckie.Name)
}
if ckie.MaxAge != -1 {
t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge)
}
}