diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt
index ed3aeab..8c8b2b0 100644
--- a/.github/actions/spelling/expect.txt
+++ b/.github/actions/spelling/expect.txt
@@ -75,6 +75,7 @@ duckduckbot
eerror
ellenjoe
enbyware
+etld
everyones
evilbot
evilsite
@@ -164,6 +165,7 @@ Linting
linuxbrew
LLU
loadbalancer
+locahost
lol
LOMINSA
maintainership
@@ -210,6 +212,7 @@ privkey
promauto
promhttp
proofofwork
+publicsuffix
pwcmd
pwuser
qualys
diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go
index f29f91c..422b439 100644
--- a/cmd/anubis/main.go
+++ b/cmd/anubis/main.go
@@ -46,6 +46,7 @@ var (
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")
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")
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")
@@ -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()
// Thoth configuration
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index ee0579c..d4c6399 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -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
- Fix an off-by-one in the default threshold config
- 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.
diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx
index dd810a3..15656b2 100644
--- a/docs/docs/admin/installation.mdx
+++ b/docs/docs/admin/installation.mdx
@@ -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_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.
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_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. |
diff --git a/lib/anubis.go b/lib/anubis.go
index 44393fb..22c32d5 100644
--- a/lib/anubis.go
+++ b/lib/anubis.go
@@ -148,21 +148,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
ckie, err := r.Cookie(s.cookieName)
if err != nil {
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)
return
}
if err := ckie.Valid(); err != nil {
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)
return
}
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
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)
return
}
@@ -171,7 +171,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if err != nil || !token.Valid {
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)
return
}
@@ -179,7 +179,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
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)
return
}
@@ -187,14 +187,14 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
policyRule, ok := claims["policyRule"].(string)
if !ok {
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)
return
}
if policyRule != rule.Hash() {
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)
return
}
@@ -216,7 +216,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
s.ServeHTTPNext(w, r)
return true
case config.RuleDeny:
- s.ClearCookie(w, s.cookieName, cookiePath)
+ s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
lg.Info("explicit deny")
if rule == nil {
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)
return true
default:
- s.ClearCookie(w, s.cookieName, cookiePath)
+ s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
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\"")
return true
@@ -302,7 +302,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
lg = lg.With("check_result", cr)
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 {
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) {
- s.ClearCookie(w, s.cookieName, cookiePath)
- s.ClearCookie(w, anubis.TestCookieName, "/")
+ s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
+ s.ClearCookie(w, anubis.TestCookieName, "/", r.Host)
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")
return
}
- s.ClearCookie(w, anubis.TestCookieName, "/")
+ s.ClearCookie(w, anubis.TestCookieName, "/", r.Host)
redir := r.FormValue("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 {
failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
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)
switch {
@@ -402,12 +402,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
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")
return
}
- s.SetCookie(w, s.cookieName, tokenString, cookiePath)
+ s.SetCookie(w, s.cookieName, tokenString, cookiePath, r.Host)
challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
lg.Debug("challenge passed, redirecting to app")
diff --git a/lib/config.go b/lib/config.go
index b882483..6c832aa 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -28,21 +28,22 @@ import (
)
type Options struct {
- Next http.Handler
- Policy *policy.ParsedConfig
- Target string
- CookieDomain string
- CookieName string
- BasePrefix string
- WebmasterEmail string
- RedirectDomains []string
- ED25519PrivateKey ed25519.PrivateKey
- HS512Secret []byte
- CookieExpiration time.Duration
- StripBasePrefix bool
- OpenGraph config.OpenGraph
- CookiePartitioned bool
- ServeRobotsTXT bool
+ Next http.Handler
+ Policy *policy.ParsedConfig
+ Target string
+ CookieDynamicDomain bool
+ CookieDomain string
+ CookieExpiration time.Duration
+ CookieName string
+ CookiePartitioned bool
+ BasePrefix string
+ WebmasterEmail string
+ RedirectDomains []string
+ ED25519PrivateKey ed25519.PrivateKey
+ HS512Secret []byte
+ StripBasePrefix bool
+ OpenGraph config.OpenGraph
+ ServeRobotsTXT bool
}
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
diff --git a/lib/http.go b/lib/http.go
index 691b1a6..664a934 100644
--- a/lib/http.go
+++ b/lib/http.go
@@ -4,6 +4,7 @@ import (
"fmt"
"math/rand"
"net/http"
+ "regexp"
"slices"
"strings"
"time"
@@ -15,21 +16,40 @@ import (
"github.com/TecharoHQ/anubis/web"
"github.com/a-h/templ"
"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{
Name: name,
Value: value,
Expires: time.Now().Add(s.opts.CookieExpiration),
SameSite: http.SameSiteLaxMode,
- Domain: s.opts.CookieDomain,
+ Domain: domain,
Partitioned: s.opts.CookiePartitioned,
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{
Name: name,
Value: "",
@@ -37,7 +57,7 @@ func (s *Server) ClearCookie(w http.ResponseWriter, name, path string) {
Expires: time.Now().Add(-1 * time.Minute),
SameSite: http.SameSiteLaxMode,
Partitioned: s.opts.CookiePartitioned,
- Domain: s.opts.CookieDomain,
+ Domain: domain,
Path: path,
})
}
diff --git a/lib/http_test.go b/lib/http_test.go
index add0706..999a9d2 100644
--- a/lib/http_test.go
+++ b/lib/http_test.go
@@ -7,11 +7,55 @@ import (
"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) {
srv := spawnAnubis(t, Options{})
rw := httptest.NewRecorder()
- srv.ClearCookie(rw, srv.cookieName, "/")
+ srv.ClearCookie(rw, srv.cookieName, "/", "localhost")
resp := rw.Result()
@@ -36,7 +80,7 @@ func TestClearCookieWithDomain(t *testing.T) {
srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"})
rw := httptest.NewRecorder()
- srv.ClearCookie(rw, srv.cookieName, "/")
+ srv.ClearCookie(rw, srv.cookieName, "/", "locahost")
resp := rw.Result()
@@ -56,3 +100,28 @@ func TestClearCookieWithDomain(t *testing.T) {
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)
+ }
+}