فهرست منبع

feat: add a strip-base-prefix option (#655)

* style: fix formatting in .air.toml and installation.mdx

* feat: add --strip-base-prefix flag to modify request paths when forwarding

Closes: #638

* refactor: apply structpacking (betteralign)

* fix: add validation for strip-base-prefix and base-prefix configuration

* fix: improve request path handling by cloning request and modifying URL path

* chore: remove integration tests as they are too annoying to debug on my system
Jason Cameron 1 هفته پیش
والد
کامیت
3b3080d497
9فایلهای تغییر یافته به همراه155 افزوده شده و 7 حذف شده
  1. 1 1
      .air.toml
  2. 6 0
      cmd/anubis/main.go
  3. 1 0
      docs/docs/CHANGELOG.md
  4. 18 4
      docs/docs/admin/installation.mdx
  5. 99 0
      lib/anubis_test.go
  6. 1 1
      lib/challenge/error.go
  7. 1 0
      lib/config.go
  8. 27 0
      lib/http.go
  9. 1 1
      lib/policy/bot.go

+ 1 - 1
.air.toml

@@ -9,4 +9,4 @@ exclude_dir = ["var", "vendor", "docs", "node_modules"]
 
 
 [logger]
 [logger]
 time = true
 time = true
-# to change flags at runtime, prepend with -- e.g. $ air -- --target http://localhost:3000 --difficulty 20 --use-remote-address
+# to change flags at runtime, prepend with -- e.g. $ air -- --target http://localhost:3000 --difficulty 20 --use-remote-address

+ 6 - 0
cmd/anubis/main.go

@@ -55,6 +55,7 @@ var (
 	policyFname              = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)")
 	policyFname              = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)")
 	redirectDomains          = flag.String("redirect-domains", "", "list of domains separated by commas which anubis is allowed to redirect to. Leaving this unset allows any domain.")
 	redirectDomains          = flag.String("redirect-domains", "", "list of domains separated by commas which anubis is allowed to redirect to. Leaving this unset allows any domain.")
 	slogLevel                = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
 	slogLevel                = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
+	stripBasePrefix          = flag.Bool("strip-base-prefix", false, "if true, strips the base prefix from requests forwarded to the target server")
 	target                   = flag.String("target", "http://localhost:3923", "target to reverse proxy to, set to an empty string to disable proxying when only using auth request")
 	target                   = flag.String("target", "http://localhost:3923", "target to reverse proxy to, set to an empty string to disable proxying when only using auth request")
 	targetSNI                = flag.String("target-sni", "", "if set, the value of the TLS handshake hostname when forwarding requests to the target")
 	targetSNI                = flag.String("target-sni", "", "if set, the value of the TLS handshake hostname when forwarding requests to the target")
 	targetHost               = flag.String("target-host", "", "if set, the value of the Host header when forwarding requests to the target")
 	targetHost               = flag.String("target-host", "", "if set, the value of the Host header when forwarding requests to the target")
@@ -260,6 +261,10 @@ func main() {
 	} else if strings.HasSuffix(*basePrefix, "/") {
 	} else if strings.HasSuffix(*basePrefix, "/") {
 		log.Fatalf("[misconfiguration] base-prefix must not end with a slash")
 		log.Fatalf("[misconfiguration] base-prefix must not end with a slash")
 	}
 	}
+	if *stripBasePrefix && *basePrefix == "" {
+		log.Fatalf("[misconfiguration] strip-base-prefix is set to true, but base-prefix is not set, " +
+			"this may result in unexpected behavior")
+	}
 
 
 	var priv ed25519.PrivateKey
 	var priv ed25519.PrivateKey
 	if *ed25519PrivateKeyHex != "" && *ed25519PrivateKeyHexFile != "" {
 	if *ed25519PrivateKeyHex != "" && *ed25519PrivateKeyHexFile != "" {
@@ -304,6 +309,7 @@ func main() {
 
 
 	s, err := libanubis.New(libanubis.Options{
 	s, err := libanubis.New(libanubis.Options{
 		BasePrefix:           *basePrefix,
 		BasePrefix:           *basePrefix,
+		StripBasePrefix:      *stripBasePrefix,
 		Next:                 rp,
 		Next:                 rp,
 		Policy:               policy,
 		Policy:               policy,
 		ServeRobotsTXT:       *robotsTxt,
 		ServeRobotsTXT:       *robotsTxt,

+ 1 - 0
docs/docs/CHANGELOG.md

@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Implement a no-JS challenge method: [`metarefresh`](./admin/configuration/challenges/metarefresh.mdx) ([#95](https://github.com/TecharoHQ/anubis/issues/95))
 - Implement a no-JS challenge method: [`metarefresh`](./admin/configuration/challenges/metarefresh.mdx) ([#95](https://github.com/TecharoHQ/anubis/issues/95))
 - Bump AI-robots.txt to version 1.34
 - Bump AI-robots.txt to version 1.34
 - Make progress bar styling more compatible (UXP, etc)
 - 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
 
 
 ## v1.19.1: Jenomis cen Lexentale - Echo 1
 ## v1.19.1: Jenomis cen Lexentale - Echo 1
 
 

+ 18 - 4
docs/docs/admin/installation.mdx

@@ -4,8 +4,6 @@ title: Setting up Anubis
 
 
 import RandomKey from "@site/src/components/RandomKey";
 import RandomKey from "@site/src/components/RandomKey";
 
 
-import Tabs from "@theme/Tabs";
-import TabItem from "@theme/TabItem";
 
 
 Anubis is meant to sit between your reverse proxy (such as Nginx or Caddy) and your target service. One instance of Anubis must be used per service you are protecting.
 Anubis is meant to sit between your reverse proxy (such as Nginx or Caddy) and your target service. One instance of Anubis must be used per service you are protecting.
 
 
@@ -32,7 +30,7 @@ TLS terminator)
 Anubis is shipped in the Docker repo [`ghcr.io/techarohq/anubis`](https://github.com/TecharoHQ/anubis/pkgs/container/anubis). The following tags exist for your convenience:
 Anubis is shipped in the Docker repo [`ghcr.io/techarohq/anubis`](https://github.com/TecharoHQ/anubis/pkgs/container/anubis). The following tags exist for your convenience:
 
 
 | Tag                 | Meaning                                                                                                                            |
 | Tag                 | Meaning                                                                                                                            |
-| :------------------ | :--------------------------------------------------------------------------------------------------------------------------------- |
+|:--------------------|:-----------------------------------------------------------------------------------------------------------------------------------|
 | `latest`            | The latest [tagged release](https://github.com/TecharoHQ/anubis/releases), if you are in doubt, start here.                        |
 | `latest`            | The latest [tagged release](https://github.com/TecharoHQ/anubis/releases), if you are in doubt, start here.                        |
 | `v<version number>` | The Anubis image for [any given tagged release](https://github.com/TecharoHQ/anubis/tags)                                          |
 | `v<version number>` | The Anubis image for [any given tagged release](https://github.com/TecharoHQ/anubis/tags)                                          |
 | `main`              | The current build on the `main` branch. Only use this if you need the latest and greatest features as they are merged into `main`. |
 | `main`              | The current build on the `main` branch. Only use this if you need the latest and greatest features as they are merged into `main`. |
@@ -50,7 +48,7 @@ For more detailed information on installing Anubis with native packages, please
 Anubis uses these environment variables for configuration:
 Anubis uses these environment variables for configuration:
 
 
 | Environment Variable           | Default value           | Explanation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
 | Environment Variable           | Default value           | Explanation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
-| :----------------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+|:-------------------------------|:------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | `BASE_PREFIX`                  | unset                   | If set, adds a global prefix to all Anubis endpoints. For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes.                                                                                                                                                                                                                                                     |
 | `BASE_PREFIX`                  | unset                   | If set, adds a global prefix to all Anubis endpoints. For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes.                                                                                                                                                                                                                                                     |
 | `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.                                                                                                                                                                                                                                                                                                                                                              |
@@ -69,6 +67,7 @@ Anubis uses these environment variables for configuration:
 | `REDIRECT_DOMAINS`             | unset                   | If set, restrict the domains that Anubis can redirect to when passing a challenge.<br/><br/>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.<br/><br/>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. |
 | `REDIRECT_DOMAINS`             | unset                   | If set, restrict the domains that Anubis can redirect to when passing a challenge.<br/><br/>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.<br/><br/>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.                                                                                                                                                                                                                |
 | `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.                                                                                                                                                                                                                                                                                                                                                               |
 | `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.                                                                                                                                                                                                                                                                                                                                                               |
+| `STRIP_BASE_PREFIX`            | `false`                 | If set to `true`, strips the base prefix from request paths when forwarding to the target server. This is useful when your target service expects to receive requests without the base prefix. For example, with `BASE_PREFIX=/foo` and `STRIP_BASE_PREFIX=true`, a request to `/foo/bar` would be forwarded to the target as `/bar`.                                                                                                                                                                   |
 | `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`.                                                                                                                                                                                                                                                                                                                                            |
 | `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`.                                                                                                                                                                                                                                                                                                                                            |
 | `USE_REMOTE_ADDRESS`           | unset                   | If set to `true`, Anubis will take the client's IP from the network socket. For production deployments, it is expected that a reverse proxy is used in front of Anubis, which pass the IP using headers, instead.                                                                                                                                                                                                                                                                                       |
 | `USE_REMOTE_ADDRESS`           | unset                   | If set to `true`, Anubis will take the client's IP from the network socket. For production deployments, it is expected that a reverse proxy is used in front of Anubis, which pass the IP using headers, instead.                                                                                                                                                                                                                                                                                       |
 | `WEBMASTER_EMAIL`              | unset                   | If set, shows a contact email address when rendering error pages. This email address will be how users can get in contact with administrators.                                                                                                                                                                                                                                                                                                                                                          |
 | `WEBMASTER_EMAIL`              | unset                   | If set, shows a contact email address when rendering error pages. This email address will be how users can get in contact with administrators.                                                                                                                                                                                                                                                                                                                                                          |
@@ -129,6 +128,21 @@ With corresponding Anubis configuration:
 BASE_PREFIX=/myapp
 BASE_PREFIX=/myapp
 ```
 ```
 
 
+#### Stripping Base Prefix
+
+If your target service doesn't expect to receive the base prefix in request paths, you can use the `STRIP_BASE_PREFIX` option:
+
+```
+BASE_PREFIX=/myapp
+STRIP_BASE_PREFIX=true
+```
+
+With this configuration:
+- A request to `/myapp/api/users` would be forwarded to your target service as `/api/users`
+- A request to `/myapp/` would be forwarded as `/`
+
+This is particularly useful when working with applications that weren't designed to handle path prefixes. However, note that if your target application generates absolute redirects or links (like `/login` instead of `./login`), these may break the subpath routing since they won't include the base prefix.
+
 ### Key generation
 ### Key generation
 
 
 To generate an ed25519 private key, you can use this command:
 To generate an ed25519 private key, you can use this command:

+ 99 - 0
lib/anubis_test.go

@@ -632,3 +632,102 @@ func TestRuleChange(t *testing.T) {
 		t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
 		t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
 	}
 	}
 }
 }
+
+func TestStripBasePrefixFromRequest(t *testing.T) {
+	testCases := []struct {
+		name            string
+		basePrefix      string
+		stripBasePrefix bool
+		requestPath     string
+		expectedPath    string
+	}{
+		{
+			name:            "strip disabled - no change",
+			basePrefix:      "/foo",
+			stripBasePrefix: false,
+			requestPath:     "/foo/bar",
+			expectedPath:    "/foo/bar",
+		},
+		{
+			name:            "strip enabled - removes prefix",
+			basePrefix:      "/foo",
+			stripBasePrefix: true,
+			requestPath:     "/foo/bar",
+			expectedPath:    "/bar",
+		},
+		{
+			name:            "strip enabled - root becomes slash",
+			basePrefix:      "/foo",
+			stripBasePrefix: true,
+			requestPath:     "/foo",
+			expectedPath:    "/",
+		},
+		{
+			name:            "strip enabled - trailing slash on base prefix",
+			basePrefix:      "/foo/",
+			stripBasePrefix: true,
+			requestPath:     "/foo/bar",
+			expectedPath:    "/bar",
+		},
+		{
+			name:            "strip enabled - no prefix match",
+			basePrefix:      "/foo",
+			stripBasePrefix: true,
+			requestPath:     "/other/bar",
+			expectedPath:    "/other/bar",
+		},
+		{
+			name:            "strip enabled - empty base prefix",
+			basePrefix:      "",
+			stripBasePrefix: true,
+			requestPath:     "/foo/bar",
+			expectedPath:    "/foo/bar",
+		},
+		{
+			name:            "strip enabled - nested path",
+			basePrefix:      "/app",
+			stripBasePrefix: true,
+			requestPath:     "/app/api/v1/users",
+			expectedPath:    "/api/v1/users",
+		},
+		{
+			name:            "strip enabled - exact match becomes root",
+			basePrefix:      "/myapp",
+			stripBasePrefix: true,
+			requestPath:     "/myapp/",
+			expectedPath:    "/",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			srv := &Server{
+				opts: Options{
+					BasePrefix:      tc.basePrefix,
+					StripBasePrefix: tc.stripBasePrefix,
+				},
+			}
+
+			req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)
+			originalPath := req.URL.Path
+
+			result := srv.stripBasePrefixFromRequest(req)
+
+			if result.URL.Path != tc.expectedPath {
+				t.Errorf("expected path %q, got %q", tc.expectedPath, result.URL.Path)
+			}
+
+			// Ensure original request is not modified when no stripping should occur
+			if !tc.stripBasePrefix || tc.basePrefix == "" || !strings.HasPrefix(tc.requestPath, strings.TrimSuffix(tc.basePrefix, "/")) {
+				if result != req {
+					t.Error("expected same request object when no modification needed")
+				}
+			} else {
+				// Ensure original request is not modified when stripping occurs
+				if req.URL.Path != originalPath {
+					t.Error("original request was modified")
+				}
+			}
+		})
+	}
+}

+ 1 - 1
lib/challenge/error.go

@@ -22,9 +22,9 @@ func NewError(verb, publicReason string, privateReason error) *Error {
 }
 }
 
 
 type Error struct {
 type Error struct {
+	PrivateReason error
 	Verb          string
 	Verb          string
 	PublicReason  string
 	PublicReason  string
-	PrivateReason error
 	StatusCode    int
 	StatusCode    int
 }
 }
 
 

+ 1 - 0
lib/config.go

@@ -36,6 +36,7 @@ type Options struct {
 	PrivateKey           ed25519.PrivateKey
 	PrivateKey           ed25519.PrivateKey
 	CookieExpiration     time.Duration
 	CookieExpiration     time.Duration
 	OGTimeToLive         time.Duration
 	OGTimeToLive         time.Duration
+	StripBasePrefix      bool
 	OGCacheConsidersHost bool
 	OGCacheConsidersHost bool
 	OGPassthrough        bool
 	OGPassthrough        bool
 	CookiePartitioned    bool
 	CookiePartitioned    bool

+ 27 - 0
lib/http.go

@@ -134,6 +134,32 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	s.mux.ServeHTTP(w, r)
 	s.mux.ServeHTTP(w, r)
 }
 }
 
 
+func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {
+	if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" {
+		return r
+	}
+
+	basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/")
+	path := r.URL.Path
+
+	if !strings.HasPrefix(path, basePrefix) {
+		return r
+	}
+
+	trimmedPath := strings.TrimPrefix(path, basePrefix)
+	if trimmedPath == "" {
+		trimmedPath = "/"
+	}
+
+	// Clone the request and URL
+	reqCopy := r.Clone(r.Context())
+	urlCopy := *r.URL
+	urlCopy.Path = trimmedPath
+	reqCopy.URL = &urlCopy
+
+	return reqCopy
+}
+
 func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
 func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
 	if s.next == nil {
 	if s.next == nil {
 		redir := r.FormValue("redir")
 		redir := r.FormValue("redir")
@@ -158,6 +184,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
 		).ServeHTTP(w, r)
 		).ServeHTTP(w, r)
 	} else {
 	} else {
 		requestsProxied.WithLabelValues(r.Host).Inc()
 		requestsProxied.WithLabelValues(r.Host).Inc()
+		r = s.stripBasePrefixFromRequest(r)
 		s.next.ServeHTTP(w, r)
 		s.next.ServeHTTP(w, r)
 	}
 	}
 }
 }

+ 1 - 1
lib/policy/bot.go

@@ -10,9 +10,9 @@ import (
 type Bot struct {
 type Bot struct {
 	Rules     Checker
 	Rules     Checker
 	Challenge *config.ChallengeRules
 	Challenge *config.ChallengeRules
+	Weight    *config.Weight
 	Name      string
 	Name      string
 	Action    config.Rule
 	Action    config.Rule
-	Weight    *config.Weight
 }
 }
 
 
 func (b Bot) Hash() string {
 func (b Bot) Hash() string {