diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index c51c86fd..c0ec483d 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -80,9 +80,10 @@ var ( publicUrl = flag.String("public-url", "", "the externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for forwardAuth).") xffStripPrivate = flag.Bool("xff-strip-private", true, "if set, strip private addresses from X-Forwarded-For") - thothInsecure = flag.Bool("thoth-insecure", false, "if set, connect to Thoth over plain HTTP/2, don't enable this unless support told you to") - thothURL = flag.String("thoth-url", "", "if set, URL for Thoth, the IP reputation database for Anubis") - thothToken = flag.String("thoth-token", "", "if set, API token for Thoth, the IP reputation database for Anubis") + thothInsecure = flag.Bool("thoth-insecure", false, "if set, connect to Thoth over plain HTTP/2, don't enable this unless support told you to") + thothURL = flag.String("thoth-url", "", "if set, URL for Thoth, the IP reputation database for Anubis") + thothToken = flag.String("thoth-token", "", "if set, API token for Thoth, the IP reputation database for Anubis") + jwtRestrictionHeader = flag.String("jwt-restriction-header", "X-Real-IP", "If set, the JWT is only valid if the current value of this header matched the value when the JWT was created") ) func keyFromHex(value string) (ed25519.PrivateKey, error) { @@ -397,23 +398,24 @@ func main() { } s, err := libanubis.New(libanubis.Options{ - BasePrefix: *basePrefix, - StripBasePrefix: *stripBasePrefix, - Next: rp, - Policy: policy, - ServeRobotsTXT: *robotsTxt, - ED25519PrivateKey: ed25519Priv, - HS512Secret: []byte(*hs512Secret), - CookieDomain: *cookieDomain, - CookieDynamicDomain: *cookieDynamicDomain, - CookieExpiration: *cookieExpiration, - CookiePartitioned: *cookiePartitioned, - RedirectDomains: redirectDomainsList, - Target: *target, - WebmasterEmail: *webmasterEmail, - OpenGraph: policy.OpenGraph, - CookieSecure: *cookieSecure, - PublicUrl: *publicUrl, + BasePrefix: *basePrefix, + StripBasePrefix: *stripBasePrefix, + Next: rp, + Policy: policy, + ServeRobotsTXT: *robotsTxt, + ED25519PrivateKey: ed25519Priv, + HS512Secret: []byte(*hs512Secret), + CookieDomain: *cookieDomain, + CookieDynamicDomain: *cookieDynamicDomain, + CookieExpiration: *cookieExpiration, + CookiePartitioned: *cookiePartitioned, + RedirectDomains: redirectDomainsList, + Target: *target, + WebmasterEmail: *webmasterEmail, + OpenGraph: policy.OpenGraph, + CookieSecure: *cookieSecure, + PublicUrl: *publicUrl, + JWTRestrictionHeader: *jwtRestrictionHeader, }) if err != nil { log.Fatalf("can't construct libanubis.Server: %v", err) diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index d6d07f43..6aa4fb52 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default `favicon` pattern in `data/common/keep-internet-working.yaml` has been updated to permit requests for png/gif/jpg/svg files as well as ico. - The `--cookie-prefix` flag has been fixed so that it is fully respected. - The default patterns in `data/common/keep-internet-working.yaml` have been updated to appropriately escape the '.' character in the regular expression patterns. +- Add optional restrictions for JWT based on the value of a header ([#697](https://github.com/TecharoHQ/anubis/pull/697)) ### Breaking changes diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx index a6b02b58..eef05815 100644 --- a/docs/docs/admin/installation.mdx +++ b/docs/docs/admin/installation.mdx @@ -69,15 +69,16 @@ 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_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 | | `DIFFICULTY` | `4` | 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. When running multiple instances on the same base domain, the key must be the same across all instances. See below for details. | +| `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. When running multiple instances on the same base domain, the key must be the same across all instances. See below for details. | | `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. | +| `JWT_RESTRICTION_HEADER` | `X-Real-IP` | If set, the JWT is only valid if the current value of this header matches the value when the JWT was created. You can use it e.g. to restrict a JWT to the source IP of the user using `X-Real-IP`. | | `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. | | `OG_EXPIRY_TIME` | `24h` | The expiration time for the Open Graph tag cache. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.mdx). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. | -| `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). | +| `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). | | `REDIRECT_DOMAINS` | unset | If set, restrict the domains that Anubis can redirect to when passing a challenge.

If this is unset, Anubis may redirect to any domain which could cause security issues in the unlikely case that an attacker passes a challenge for your browser and then tricks you into clicking a link to your domain.

Note that if you are hosting Anubis on a non-standard port (`https://example:com:8443`, `http://www.example.net:8080`, etc.), you must also include the port number here. | | `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. | | `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. | diff --git a/lib/anubis.go b/lib/anubis.go index d23df3f2..af7238d8 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -237,6 +237,13 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS return } + if s.opts.JWTRestrictionHeader != "" && claims["restriction"] != internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader)) { + lg.Debug("JWT restriction header is invalid") + s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) + s.RenderIndex(w, r, cr, rule, httpStatusOnly) + return + } + r.Header.Add("X-Anubis-Status", "PASS") s.ServeHTTPNext(w, r) } @@ -484,12 +491,33 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { } // generate JWT cookie - tokenString, err := s.signJWT(jwt.MapClaims{ - "challenge": chall.ID, - "method": rule.Challenge.Algorithm, - "policyRule": rule.Hash(), - "action": string(cr.Rule), - }) + var tokenString string + + // check if JWTRestrictionHeader is set and header is in request + if s.opts.JWTRestrictionHeader != "" { + if r.Header.Get(s.opts.JWTRestrictionHeader) == "" { + lg.Error("JWTRestrictionHeader is set in config but not found in request, please check your reverse proxy config.") + s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) + s.respondWithError(w, r, "failed to sign JWT") + return + } else { + tokenString, err = s.signJWT(jwt.MapClaims{ + "challenge": chall.ID, + "method": rule.Challenge.Algorithm, + "policyRule": rule.Hash(), + "action": string(cr.Rule), + "restriction": internal.SHA256sum(r.Header.Get(s.opts.JWTRestrictionHeader)), + }) + } + } else { + tokenString, err = s.signJWT(jwt.MapClaims{ + "challenge": chall.ID, + "method": rule.Challenge.Algorithm, + "policyRule": rule.Hash(), + "action": string(cr.Rule), + }) + } + if err != nil { lg.Error("failed to sign JWT", "err", err) s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) diff --git a/lib/config.go b/lib/config.go index 7f5d5285..101d0abc 100644 --- a/lib/config.go +++ b/lib/config.go @@ -27,24 +27,25 @@ import ( ) type Options struct { - Next http.Handler - Policy *policy.ParsedConfig - Target string - CookieDynamicDomain bool - CookieDomain string - CookieExpiration time.Duration - CookiePartitioned bool - BasePrefix string - WebmasterEmail string - RedirectDomains []string - ED25519PrivateKey ed25519.PrivateKey - HS512Secret []byte - StripBasePrefix bool - OpenGraph config.OpenGraph - ServeRobotsTXT bool - CookieSecure bool - Logger *slog.Logger - PublicUrl string + Next http.Handler + Policy *policy.ParsedConfig + Target string + CookieDynamicDomain bool + CookieDomain string + CookieExpiration time.Duration + CookiePartitioned bool + BasePrefix string + WebmasterEmail string + RedirectDomains []string + ED25519PrivateKey ed25519.PrivateKey + HS512Secret []byte + StripBasePrefix bool + OpenGraph config.OpenGraph + ServeRobotsTXT bool + CookieSecure bool + Logger *slog.Logger + PublicUrl string + JWTRestrictionHeader string } func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {