From 019dcdb8bc8b60f051581aea5eb7631b2951ee6a Mon Sep 17 00:00:00 2001 From: nyyu Date: Fri, 25 Apr 2025 21:11:51 +0200 Subject: [PATCH 1/9] feat: support HTTP redirect for forward authentication middleware in Traefik --- cmd/anubis/main.go | 3 + docs/docs/CHANGELOG.md | 1 + docs/docs/admin/environments/traefik.mdx | 104 +++++------------------ docs/docs/admin/installation.mdx | 2 + internal/headers.go | 2 +- lib/config.go | 1 + lib/http.go | 14 ++- lib/http_test.go | 58 +++++++++++++ 8 files changed, 98 insertions(+), 87 deletions(-) diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index b500a57..0fa107a 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -68,6 +68,7 @@ var ( extractResources = flag.String("extract-resources", "", "if set, extract the static resources to the specified folder") webmasterEmail = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals") versionFlag = flag.Bool("version", false, "print Anubis version") + publicUrl = flag.String("public-url", "", "the externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for forwardAuth).") ) func keyFromHex(value string) (ed25519.PrivateKey, error) { @@ -316,6 +317,7 @@ func main() { Target: *target, WebmasterEmail: *webmasterEmail, OGCacheConsidersHost: *ogCacheConsiderHost, + PublicUrl: *publicUrl, }) if err != nil { log.Fatalf("can't construct libanubis.Server: %v", err) @@ -354,6 +356,7 @@ func main() { "base-prefix", *basePrefix, "cookie-expiration-time", *cookieExpiration, "rule-error-ids", ruleErrorIDs, + "public-url", *publicUrl, ) go func() { diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index b800c3c..21e2d53 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Added support to use Traefik forwardAuth middleware - Refactor challenge presentation logic to use a challenge registry - Allow challenge implementations to register HTTP routes diff --git a/docs/docs/admin/environments/traefik.mdx b/docs/docs/admin/environments/traefik.mdx index 81e1499..2a09ed9 100644 --- a/docs/docs/admin/environments/traefik.mdx +++ b/docs/docs/admin/environments/traefik.mdx @@ -10,10 +10,6 @@ but it also applies to docker cli options. ::: -Currently, Anubis doesn't have any Traefik middleware, -so you need to manually route it between Traefik and your target service. -This routing is done per labels in Traefik. - In this example, we will use 4 Containers: - `traefik` - the Traefik instance @@ -21,12 +17,6 @@ In this example, we will use 4 Containers: - `target` - our service to protect (`traefik/whoami` in this case) - `target2` - a second service that isn't supposed to be protected (`traefik/whoami` in this case) -There are 3 steps we need to follow: - -1. Create a new exclusive Traefik endpoint for Anubis -2. Pass all unspecified requests to Anubis -3. Let Anubis pass all verified requests back to Traefik on its exclusive endpoint - ## Diagram of Flow This is a small diagram depicting the flow. @@ -40,74 +30,16 @@ anubis[Anubis] target[Target] user-->|:443 - Requesting Service|traefik -traefik-->|:8080 - Passing to Anubis|anubis -anubis-->|:3923 - Passing back to Traefik|traefik +traefik-->|:8080 - Check authorization to Anubis|anubis +anubis-->|redirect if failed|traefik +user-->|:8080 - make the challenge|traefik +anubis-->|redirect back to target|traefik traefik-->|:80 - Passing to the target|target ``` -## Create an Exclusive Anubis Endpoint in Traefik - -There are 2 ways of registering a new endpoint in Traefik. -Which one to use depends on how you configured your Traefik so far. - -**CLI Options:** - -```yml ---entrypoints.anubis.address=:3923 -``` - -**traefik.yml:** - -```yml -entryPoints: - anubis: - address: ":3923" -``` - -It is important that the specified port isn't actually reachable from the outside, -but only exposed in the Docker network. -Exposing the Anubis port on Traefik directly will allow direct unprotected access to all containers behind it. - -## Passing all unspecified Web Requests to Anubis - -There are cases where you want Traefik to still route some requests without protection, just like before. -To achieve this, we can register Anubis as the default handler for non-protected requests. - -We also don't want users to get SSL Errors during the checking phase, -thus we also need to let Traefik provide SSL Certs for our endpoint. -This example expects an TLS cert resolver called `le`. - -We also expect there to be an endpoint called `websecure` for HTTPS in this example. - -This is an example of the required labels to configure Traefik on the Anubis container: - -```yml -labels: - - traefik.enable=true # Enabling Traefik - - traefik.docker.network=traefik # Telling Traefik which network to use - - traefik.http.routers.anubis.priority=1 # Setting Anubis to the lowest priority, so it only takes the slack - - traefik.http.routers.anubis.rule=PathRegexp(`.*`) # Wildcard match every path - - traefik.http.routers.anubis.entrypoints=websecure # Listen on HTTPS - - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik to which port it should route requests - - traefik.http.routers.anubis.service=anubis # Telling Traefik to use the above specified port - - traefik.http.routers.anubis.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis -``` - -## Passing all Verified Requests Back Correctly to Traefik - -To pass verified requests back to Traefik, -we only need to configure Anubis using its environment variables: - -```yml -environment: - - BIND=:8080 - - TARGET=http://traefik:3923 -``` - ## Full Example Config -Now that we know how to pass all requests back and forth, here is the example. -This example contains 2 services: one that is protected and the other one that is not. +This example contains 3 services: anubis, one that is protected and the other one that is not. **compose.yml** @@ -128,6 +60,8 @@ services: # Enable Traefik - traefik.enable=true - traefik.docker.network=traefik + # Anubis middleware + - traefik.http.middlewares.anubis.forwardauth.address=http://anubis:8080/.within.website/x/cmd/anubis/api/check # Redirect any HTTP to HTTPS - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https - traefik.http.routers.web.rule=PathPrefix(`/`) @@ -140,17 +74,22 @@ services: environment: # Telling Anubis, where to listen for Traefik - BIND=:8080 - # Telling Anubis to point to Traefik via the Docker network - - TARGET=http://traefik:3923 + # Telling Anubis to do redirect + - TARGET= + # Specifies which domains Anubis is allowed to redirect to. + - REDIRECT_DOMAINS=example.com + # Should be the full external URL for Anubis (including scheme) + - PUBLIC_URL=https://anubis.example.com + # Should match your domain for proper cookie scoping + - COOKIE_NAME=example.com networks: - traefik labels: - traefik.enable=true # Enabling Traefik - traefik.docker.network=traefik # Telling Traefik which network to use - - traefik.http.routers.anubis.priority=1 # Setting Anubis to the lowest priority, so it only takes the slack - - traefik.http.routers.anubis.rule=PathRegexp(`.*`) # wildcard match anything + - traefik.http.routers.anubis.rule=Host(`anubis.example.com`) # Only Matching Requests for example.com - traefik.http.routers.anubis.entrypoints=websecure # Listen on HTTPS - - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik to which port it should route requests + - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik where to receive requests - traefik.http.routers.anubis.service=anubis # Telling Traefik to use the above specified port - traefik.http.routers.anubis.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis @@ -163,9 +102,11 @@ services: - traefik.enable=true # Enabling Traefik - traefik.docker.network=traefik # Telling Traefik which network to use - traefik.http.routers.target.rule=Host(`example.com`) # Only Matching Requests for example.com - - traefik.http.routers.target.entrypoints=anubis # Listening on the exclusive Anubis Network + - traefik.http.routers.target.entrypoints=websecure # Listening on the exclusive Anubis Network - traefik.http.services.target.loadbalancer.server.port=80 # Telling Traefik where to receive requests - traefik.http.routers.target.service=target # Telling Traefik to use the above specified port + - traefik.http.routers.target.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis + - traefik.http.routers.target.middlewares=anubis@docker # Use the Anubis middlware # Not Protected by Anubis target2: @@ -175,7 +116,7 @@ services: labels: - traefik.enable=true # Enabling Traefik - traefik.docker.network=traefik # Telling Traefik which network to use - - traefik.http.routers.target2.rule=Host(`another.com`) # Only Matching Requests for example.com + - traefik.http.routers.target2.rule=Host(`another.example.com`) # Only Matching Requests for example.com - traefik.http.routers.target2.entrypoints=websecure # Listening on the exclusive Anubis Network - traefik.http.services.target2.loadbalancer.server.port=80 # Telling Traefik where to receive requests - traefik.http.routers.target2.service=target2 # Telling Traefik to use the above specified port @@ -198,9 +139,6 @@ entryPoints: address: ":80" websecure: address: ":443" - # Anubis - anubis: - address: ":3923" certificatesResolvers: le: diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx index 5db3b8a..b9097ec 100644 --- a/docs/docs/admin/installation.mdx +++ b/docs/docs/admin/installation.mdx @@ -67,6 +67,8 @@ Anubis uses these environment variables for configuration: | `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. | | `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. | | `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. | +| `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. | | `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. | | `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`. | diff --git a/internal/headers.go b/internal/headers.go index 2596a02..9c4f59c 100644 --- a/internal/headers.go +++ b/internal/headers.go @@ -153,7 +153,7 @@ func computeXFFHeader(remoteAddr string, origXFFHeader string, pref XFFComputePr // generally they'd be expected to do these two things on // their own end to find the first non-spoofed IP for i := len(origForwardedList) - 1; i >= 0; i-- { - segmentIP, err := netip.ParseAddr(origForwardedList[i]) + segmentIP, err := netip.ParseAddr(strings.TrimSpace(origForwardedList[i])) if err != nil { // can't assess this element, so the remainder of the chain // can't be trusted. not a fatal error, since anyone can diff --git a/lib/config.go b/lib/config.go index 9bb78e8..cf9c3fb 100644 --- a/lib/config.go +++ b/lib/config.go @@ -40,6 +40,7 @@ type Options struct { OGPassthrough bool CookiePartitioned bool ServeRobotsTXT bool + PublicUrl string } func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) { diff --git a/lib/http.go b/lib/http.go index e2400a6..73ef81d 100644 --- a/lib/http.go +++ b/lib/http.go @@ -4,6 +4,7 @@ import ( "fmt" "math/rand" "net/http" + "net/url" "slices" "strings" "time" @@ -64,8 +65,14 @@ func randomChance(n int) bool { func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) { if returnHTTPStatusOnly { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Authorization required")) + if s.opts.PublicUrl == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Authorization required")) + } else { + redir := r.Header.Get("X-Forwarded-Proto") + "://" + r.Header.Get("X-Forwarded-Host") + r.Header.Get("X-Forwarded-Uri") + escapedURL := url.QueryEscape(redir) + http.Redirect(w, r, fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), http.StatusTemporaryRedirect) + } return } @@ -143,7 +150,8 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) { return } - if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host { + if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || + (r.URL.Host != "" && urlParsed.Host != r.URL.Host) { s.respondWithStatus(w, r, "Redirect domain not allowed", http.StatusBadRequest) return } diff --git a/lib/http_test.go b/lib/http_test.go index add0706..856d0a3 100644 --- a/lib/http_test.go +++ b/lib/http_test.go @@ -1,7 +1,9 @@ package lib import ( + "net/http" "net/http/httptest" + "net/url" "testing" "github.com/TecharoHQ/anubis" @@ -56,3 +58,59 @@ func TestClearCookieWithDomain(t *testing.T) { t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge) } } + +func TestRenderIndexRedirect(t *testing.T) { + s := &Server{ + opts: Options{ + PublicUrl: "https://anubis.example.com", + }, + } + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "example.com") + req.Header.Set("X-Forwarded-Uri", "/foo") + + rr := httptest.NewRecorder() + s.RenderIndex(rr, req, nil, true) + + if rr.Code != http.StatusTemporaryRedirect { + t.Errorf("expected status %d, got %d", http.StatusTemporaryRedirect, rr.Code) + } + location := rr.Header().Get("Location") + parsedURL, _ := url.Parse(location) + + scheme := "https" + if parsedURL.Scheme != scheme { + t.Errorf("expected scheme to be %q, got %q", scheme, parsedURL.Scheme) + } + + host := "anubis.example.com" + if parsedURL.Host != host { + t.Errorf("expected url to be %q, got %q", host, parsedURL.Host) + } + + redir := parsedURL.Query().Get("redir") + expectedRedir := "https://example.com/foo" + if redir != expectedRedir { + t.Errorf("expected redir param to be %q, got %q", expectedRedir, redir) + } +} + +func TestRenderIndexUnauthorized(t *testing.T) { + s := &Server{ + opts: Options{ + PublicUrl: "", + }, + } + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + s.RenderIndex(rr, req, nil, true) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) + } + if body := rr.Body.String(); body != "Authorization required" { + t.Errorf("expected body %q, got %q", "Authorization required", body) + } +} From 81d581477ce13df9d877477ac32f8ec6259a278d Mon Sep 17 00:00:00 2001 From: Jason Cameron Date: Thu, 12 Jun 2025 22:55:36 -0400 Subject: [PATCH 2/9] fix(docs): fix my terrible merge Signed-off-by: Jason Cameron --- docs/docs/admin/installation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx index 20a62f1..08c3d0f 100644 --- a/docs/docs/admin/installation.mdx +++ b/docs/docs/admin/installation.mdx @@ -64,7 +64,7 @@ Anubis uses these environment variables for configuration: | `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. | | `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. | | `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 r +| `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. | From 515ce9dd09bd018cb4a3db4acfcc42f8b460189f Mon Sep 17 00:00:00 2001 From: Jason Cameron Date: Thu, 12 Jun 2025 23:01:33 -0400 Subject: [PATCH 3/9] chore: fix typo in docs Signed-off-by: Jason Cameron --- docs/docs/admin/environments/traefik.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/admin/environments/traefik.mdx b/docs/docs/admin/environments/traefik.mdx index 2a09ed9..054d3a8 100644 --- a/docs/docs/admin/environments/traefik.mdx +++ b/docs/docs/admin/environments/traefik.mdx @@ -106,7 +106,7 @@ services: - traefik.http.services.target.loadbalancer.server.port=80 # Telling Traefik where to receive requests - traefik.http.routers.target.service=target # Telling Traefik to use the above specified port - traefik.http.routers.target.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis - - traefik.http.routers.target.middlewares=anubis@docker # Use the Anubis middlware + - traefik.http.routers.target.middlewares=anubis@docker # Use the Anubis middleware # Not Protected by Anubis target2: From 5038050816817a8d601887ed36c838ab776ea474 Mon Sep 17 00:00:00 2001 From: Jason Cameron Date: Thu, 12 Jun 2025 23:04:11 -0400 Subject: [PATCH 4/9] fix(ci): add forwardauth Signed-off-by: Jason Cameron --- .github/actions/spelling/expect.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 7d0e6ed..1e7b7c2 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -86,6 +86,7 @@ Firecrawl flagenv Fordola forgejo +forwardauth fsys fullchain Galvus From eb03597dd3c484869cd2e56789304aa19c7d6bc2 Mon Sep 17 00:00:00 2001 From: nyyu Date: Sat, 14 Jun 2025 08:26:49 +0200 Subject: [PATCH 5/9] chore: improve doc, target must be a space --- docs/docs/admin/environments/traefik.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/admin/environments/traefik.mdx b/docs/docs/admin/environments/traefik.mdx index 054d3a8..dc3e92a 100644 --- a/docs/docs/admin/environments/traefik.mdx +++ b/docs/docs/admin/environments/traefik.mdx @@ -74,8 +74,8 @@ services: environment: # Telling Anubis, where to listen for Traefik - BIND=:8080 - # Telling Anubis to do redirect - - TARGET= + # Telling Anubis to do redirect — ensure there is a space after '=' + - TARGET= # Specifies which domains Anubis is allowed to redirect to. - REDIRECT_DOMAINS=example.com # Should be the full external URL for Anubis (including scheme) From f28426fa4dfb40e4c185b415ecdfe04cb8143e42 Mon Sep 17 00:00:00 2001 From: nyyu Date: Sat, 14 Jun 2025 08:27:51 +0200 Subject: [PATCH 6/9] chore: changelog --- docs/docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 6ba80b1..09b1828 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -10,7 +10,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- Added support to use Traefik forwardAuth middleware - Remove the unused `/test-error` endpoint and update the testing endpoint `/make-challenge` to only be enabled in development @@ -22,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump AI-robots.txt to version 1.34 - Make progress bar styling more compatible (UXP, etc) - Add `--strip-base-prefix` flag/envvar to strip the base prefix from request paths when forwarding to target servers +- Added support to use Traefik forwardAuth middleware ## v1.19.1: Jenomis cen Lexentale - Echo 1 From 3f9d87aabb2b14eeff89db5dd5a2df92f0897ee4 Mon Sep 17 00:00:00 2001 From: nyyu Date: Sat, 14 Jun 2025 08:36:27 +0200 Subject: [PATCH 7/9] fix: validate X-Forwarded headers and check redirect domain --- lib/http.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/http.go b/lib/http.go index b6a708d..905ab6d 100644 --- a/lib/http.go +++ b/lib/http.go @@ -69,7 +69,21 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Authorization required")) } else { - redir := r.Header.Get("X-Forwarded-Proto") + "://" + r.Header.Get("X-Forwarded-Host") + r.Header.Get("X-Forwarded-Uri") + proto := r.Header.Get("X-Forwarded-Proto") + host := r.Header.Get("X-Forwarded-Host") + uri := r.Header.Get("X-Forwarded-Uri") + + if proto == "" || host == "" || uri == "" { + s.respondWithStatus(w, r, "Missing required X-Forwarded-* headers", http.StatusBadRequest) + return + } + // Check if host is allowed in RedirectDomains + if len(s.opts.RedirectDomains) > 0 && !slices.Contains(s.opts.RedirectDomains, host) { + s.respondWithStatus(w, r, "Redirect domain not allowed", http.StatusBadRequest) + return + } + + redir := proto + "://" + host + uri escapedURL := url.QueryEscape(redir) http.Redirect(w, r, fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), http.StatusTemporaryRedirect) } From bfd71045d1f77c9e2960fa03e8793bbdd7f2cb6b Mon Sep 17 00:00:00 2001 From: nyyu Date: Wed, 18 Jun 2025 15:48:51 +0200 Subject: [PATCH 8/9] chore: refactor error handling --- lib/http.go | 46 +++++++++++++++++++++++++++++----------------- lib/http_test.go | 5 ++++- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/lib/http.go b/lib/http.go index 905ab6d..9c16b3f 100644 --- a/lib/http.go +++ b/lib/http.go @@ -1,6 +1,7 @@ package lib import ( + "errors" "fmt" "math/rand" "net/http" @@ -69,23 +70,12 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Authorization required")) } else { - proto := r.Header.Get("X-Forwarded-Proto") - host := r.Header.Get("X-Forwarded-Host") - uri := r.Header.Get("X-Forwarded-Uri") - - if proto == "" || host == "" || uri == "" { - s.respondWithStatus(w, r, "Missing required X-Forwarded-* headers", http.StatusBadRequest) + redirectURL, err := s.constructRedirectURL(r) + if err != nil { + s.respondWithStatus(w, r, err.Error(), http.StatusBadRequest) return } - // Check if host is allowed in RedirectDomains - if len(s.opts.RedirectDomains) > 0 && !slices.Contains(s.opts.RedirectDomains, host) { - s.respondWithStatus(w, r, "Redirect domain not allowed", http.StatusBadRequest) - return - } - - redir := proto + "://" + host + uri - escapedURL := url.QueryEscape(redir) - http.Redirect(w, r, fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), http.StatusTemporaryRedirect) + http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) } return } @@ -137,6 +127,24 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic handler.ServeHTTP(w, r) } +func (s *Server) constructRedirectURL(r *http.Request) (string, error) { + proto := r.Header.Get("X-Forwarded-Proto") + host := r.Header.Get("X-Forwarded-Host") + uri := r.Header.Get("X-Forwarded-Uri") + + if proto == "" || host == "" || uri == "" { + return "", errors.New("missing required X-Forwarded-* headers") + } + // Check if host is allowed in RedirectDomains + if len(s.opts.RedirectDomains) > 0 && !slices.Contains(s.opts.RedirectDomains, host) { + return "", errors.New("redirect domain not allowed") + } + + redir := proto + "://" + host + uri + escapedURL := url.QueryEscape(redir) + return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil +} + func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) { templ.Handler( web.Base("Benchmarking Anubis!", web.Bench()), @@ -190,8 +198,12 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) { return } - if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || - (r.URL.Host != "" && urlParsed.Host != r.URL.Host) { + hostNotAllowed := len(urlParsed.Host) > 0 && + len(s.opts.RedirectDomains) != 0 && + !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) + hostMismatch := r.URL.Host != "" && urlParsed.Host != r.URL.Host + + if hostNotAllowed || hostMismatch { s.respondWithStatus(w, r, "Redirect domain not allowed", http.StatusBadRequest) return } diff --git a/lib/http_test.go b/lib/http_test.go index 856d0a3..c4b2527 100644 --- a/lib/http_test.go +++ b/lib/http_test.go @@ -77,7 +77,10 @@ func TestRenderIndexRedirect(t *testing.T) { t.Errorf("expected status %d, got %d", http.StatusTemporaryRedirect, rr.Code) } location := rr.Header().Get("Location") - parsedURL, _ := url.Parse(location) + parsedURL, err := url.Parse(location) + if err != nil { + t.Fatalf("failed to parse location URL %q: %v", location, err) + } scheme := "https" if parsedURL.Scheme != scheme { From 7066267183e070e20035401192df7ad37a071c0e Mon Sep 17 00:00:00 2001 From: nyyu Date: Sun, 20 Jul 2025 09:23:55 +0200 Subject: [PATCH 9/9] fix(doc): cookie traefik --- docs/docs/admin/environments/traefik.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/admin/environments/traefik.mdx b/docs/docs/admin/environments/traefik.mdx index dc3e92a..b791471 100644 --- a/docs/docs/admin/environments/traefik.mdx +++ b/docs/docs/admin/environments/traefik.mdx @@ -81,7 +81,7 @@ services: # Should be the full external URL for Anubis (including scheme) - PUBLIC_URL=https://anubis.example.com # Should match your domain for proper cookie scoping - - COOKIE_NAME=example.com + - COOKIE_DOMAIN=example.com networks: - traefik labels: