diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index f47acec..47bafd1 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -50,8 +50,9 @@ var ( socketMode = flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.") robotsTxt = flag.Bool("serve-robots-txt", false, "serve a robots.txt file that disallows all robots") 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.") slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)") - target = flag.String("target", "http://localhost:3923", "target to reverse proxy to") + 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") healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis") useRemoteAddress = flag.Bool("use-remote-address", false, "read the client's IP address from the network request, useful for debugging and running Anubis on bare metal") debugBenchmarkJS = flag.Bool("debug-benchmark-js", false, "respond to every request with a challenge for benchmarking hashrate") @@ -195,9 +196,14 @@ func main() { return } - rp, err := makeReverseProxy(*target) - if err != nil { - log.Fatalf("can't make reverse proxy: %v", err) + var rp http.Handler + // when using anubis via Systemd and environment variables, then it is not possible to set targe to an empty string but only to space + if strings.TrimSpace(*target) != "" { + var err error + rp, err = makeReverseProxy(*target) + if err != nil { + log.Fatalf("can't make reverse proxy: %v", err) + } } policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, *challengeDifficulty) @@ -252,6 +258,20 @@ func main() { slog.Warn("generating random key, Anubis will have strange behavior when multiple instances are behind the same load balancer target, for more information: see https://anubis.techaro.lol/docs/admin/installation#key-generation") } + var redirectDomainsList []string + if *redirectDomains != "" { + domains := strings.Split(*redirectDomains, ",") + for _, domain := range domains { + _, err = url.Parse(domain) + if err != nil { + log.Fatalf("cannot parse redirect-domain %q: %s", domain, err.Error()) + } + redirectDomainsList = append(redirectDomainsList, strings.TrimSpace(domain)) + } + } else { + slog.Warn("REDIRECT_DOMAINS is not set, Anubis will only redirect to the same domain a request is coming from, see https://anubis.techaro.lol/docs/admin/configuration/redirect-domains") + } + s, err := libanubis.New(libanubis.Options{ Next: rp, Policy: policy, @@ -261,6 +281,7 @@ func main() { CookiePartitioned: *cookiePartitioned, OGPassthrough: *ogPassthrough, OGTimeToLive: *ogTimeToLive, + RedirectDomains: redirectDomainsList, Target: *target, WebmasterEmail: *webmasterEmail, }) diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index d824edd..128014c 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor check logic to be more generic and work on a Checker type - Add more AI user agents based on the [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) project - Embedded challenge data in initial HTML response to improve performance +- Added support to use Nginx' `auth_request` directive with Anubis +- Added support to allow to restrict the allowed redirect domains - Whitelisted [DuckDuckBot](https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot/) in botPolicies - Improvements to build scripts to make them less independent of the build host - Improved the OpenGraph error logging diff --git a/docs/docs/admin/configuration/redirect-domains.mdx b/docs/docs/admin/configuration/redirect-domains.mdx new file mode 100644 index 0000000..4181143 --- /dev/null +++ b/docs/docs/admin/configuration/redirect-domains.mdx @@ -0,0 +1,94 @@ +--- +title: Redirect Domain Configuration +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +Anubis has an HTTP redirect in the middle of its check validation logic. This redirect allows Anubis to set a cookie on validated requests so that users don't need to pass challenges on every page load. + +This flow looks something like this: + +```mermaid +sequenceDiagram + participant User + participant Challenge + participant Validation + participant Backend + + User->>+Challenge: GET / + Challenge->>+User: Solve this challenge + User->>+Validation: Here's the solution, send me to / + Validation->>+User: Here's a cookie, go to / + User->>+Backend: GET / +``` + +However, in some cases a sufficiently dedicated attacker could trick a user into clicking on a validation link with a solution pre-filled out. For example: + +```mermaid +sequenceDiagram + participant Hacker + participant User + participant Validation + participant Evil Site + + Hacker->>+User: Click on yoursite.com with this solution + User->>+Validation: Here's a solution, send me to evilsite.com + Validation->>+User: Here's a cookie, go to evilsite.com + User->>+Evil Site: GET evilsite.com +``` + +If this happens, Anubis will throw an error like this: + +```text +Redirect domain not allowed +``` + +## Configuring allowed redirect domains + +By default, Anubis will limit redirects to be on the same HTTP Host that Anubis is running on (EG: requests to yoursite.com cannot redirect outside of yoursite.com). If you need to set more than one domain, fill the `REDIRECT_DOMAINS` environment variable with a comma-separated list of domain names that Anubis should allow redirects to. + +:::note + +These domains are _an exact string match_, they do not support wildcard matches. + +::: + + + + +```shell +# anubis.env + +REDIRECT_DOMAINS="yoursite.com,secretplans.yoursite.com" +# ... +``` + + + + +```yaml +services: + anubis-nginx: + image: ghcr.io/techarohq/anubis:latest + environment: + REDIRECT_DOMAINS: "yoursite.com,secretplans.yoursite.com" + # ... +``` + + + + +Inside your Deployment, StatefulSet, or Pod: + +```yaml +- name: anubis + image: ghcr.io/techarohq/anubis:latest + env: + - name: REDIRECT_DOMAINS + value: "yoursite.com,secretplans.yoursite.com" + # ... +``` + + + diff --git a/docs/docs/admin/configuration/subrequest-auth.mdx b/docs/docs/admin/configuration/subrequest-auth.mdx new file mode 100644 index 0000000..a4bbda6 --- /dev/null +++ b/docs/docs/admin/configuration/subrequest-auth.mdx @@ -0,0 +1,139 @@ +--- +title: Subrequest Authentication +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +Anubis can act in one of two modes: + +1. Reverse proxy (the default): Anubis sits in the middle of all traffic and then will reverse proxy it to its destination. This is the moral equivalent of a middleware in your favorite web framework. +2. Subrequest authentication mode: Anubis listens for requests and if they don't pass muster then they are forwarded to Anubis for challenge processing. This is the equivalent of Anubis being a sidecar service. + +## Nginx + +Anubis can perform [subrequest authentication](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/) with the `auth_request` module in Nginx. In order to set this up, keep the following things in mind: + +The `TARGET` environment variable in Anubis must be set to a space, eg: + + + + +```shell +# anubis.env + +TARGET=" " +# ... +``` + + + + +```yaml +services: + anubis-nginx: + image: ghcr.io/techarohq/anubis:latest + environment: + TARGET: " " + # ... +``` + + + + +Inside your Deployment, StatefulSet, or Pod: + +```yaml +- name: anubis + image: ghcr.io/techarohq/anubis:latest + env: + - name: TARGET + value: " " + # ... +``` + + + + +In order to configure this, you need to add the following location blocks to each server pointing to the service you want to protect: + +```nginx +location /.within.website/ { + # Assumption: Anubis is running in the same network namespace as + # nginx on localhost TCP port 8923 + proxy_pass http://127.0.0.1:8923; + auth_request off; +} + +location @redirectToAnubis { + return 307 /.within.website/?redir=$scheme://$host$request_uri; + auth_request off; +} +``` + +This sets up `/.within.website` to point to Anubis. Any requests that Anubis rejects or throws a challenge to will be sent here. This also sets up a named location `@redirectToAnubis` that will redirect any requests to Anubis for advanced processing. + +Finally, add this to your root location block: + +```nginx +location / { + # diff-add + auth_request /.within.website/x/cmd/anubis/api/check; + # diff-add + error_page 401 = @redirectToAnubis; +} +``` + +This will check all requests that don't match other locations with Anubis to ensure the client is genuine. + +This will make every request get checked by Anubis before it hits your backend. If you have other locations that don't need Anubis to do validation, add the `auth_request off` directive to their blocks: + +```nginx +location /secret { + # diff-add + auth_request off; + + # ... +} +``` + +Here is a complete example of an Nginx server listening over TLS and pointing to Anubis: + +
+ Complete example + +```nginx +# /etc/nginx/conf.d/nginx.local.cetacean.club.conf + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name nginx.local.cetacean.club; + ssl_certificate /etc/techaro/pki/nginx.local.cetacean.club/tls.crt; + ssl_certificate_key /etc/techaro/pki/nginx.local.cetacean.club/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + location /.within.website/ { + proxy_pass http://localhost:8923; + auth_request off; + } + + location @redirectToAnubis { + return 307 /.within.website/?redir=$scheme://$host$request_uri; + auth_request off; + } + + location / { + auth_request /.within.website/x/cmd/anubis/api/check; + error_page 401 = @redirectToAnubis; + root /usr/share/nginx/html; + index index.html index.htm; + } +} +``` + +
diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx index 1070ccb..d0dc725 100644 --- a/docs/docs/admin/installation.mdx +++ b/docs/docs/admin/installation.mdx @@ -49,25 +49,26 @@ For more detailed information on installing Anubis with native packages, please Anubis uses these environment variables for configuration: -| Environment Variable | Default value | Explanation | -| :----------------------------- | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 [here](https://stackoverflow.com/a/1063760) for more information. | -| `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. | -| `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. 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. | -| `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. | -| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. | -| `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. | -| `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`. | -| `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. | +| Environment Variable | Default value | Explanation | +| :----------------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 [here](https://stackoverflow.com/a/1063760) for more information. | +| `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. | +| `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. 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. | +| `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. | +| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. | +| `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. | +| `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`. | +| `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. | For more detailed information on configuring Open Graph tags, please refer to the [Open Graph Configuration](./configuration/open-graph.mdx) page. diff --git a/lib/anubis.go b/lib/anubis.go index f6445fb..8ca6964 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "os" + "slices" "strconv" "strings" "time" @@ -64,10 +65,11 @@ var ( ) type Options struct { - Next http.Handler - Policy *policy.ParsedConfig - ServeRobotsTXT bool - PrivateKey ed25519.PrivateKey + Next http.Handler + Policy *policy.ParsedConfig + RedirectDomains []string + ServeRobotsTXT bool + PrivateKey ed25519.PrivateKey CookieDomain string CookieName string @@ -148,9 +150,10 @@ func New(opts Options) (*Server, error) { mux.HandleFunc("POST /.within.website/x/cmd/anubis/api/make-challenge", result.MakeChallenge) mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/pass-challenge", result.PassChallenge) + mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/check", result.maybeReverseProxyHttpStatusOnly) mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/test-error", result.TestError) - mux.HandleFunc("/", result.MaybeReverseProxy) + mux.HandleFunc("/", result.maybeReverseProxyOrPage) result.mux = mux @@ -172,6 +175,36 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } +func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) { + if s.next == nil { + redir := r.FormValue("redir") + urlParsed, err := r.URL.Parse(redir) + if err != nil { + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect URL not parseable", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + if len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) { + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } else if urlParsed.Host != r.URL.Host { + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + if redir != "" { + http.Redirect(w, r, redir, http.StatusFound) + return + } + + templ.Handler( + web.Base("You are not a bot!", web.StaticHappy()), + ).ServeHTTP(w, r) + } else { + s.next.ServeHTTP(w, r) + } +} + func (s *Server) challengeFor(r *http.Request, difficulty int) string { fp := sha256.Sum256(s.priv.Seed()) @@ -187,7 +220,15 @@ func (s *Server) challengeFor(r *http.Request, difficulty int) string { return internal.SHA256sum(challengeData) } -func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { +func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) { + s.maybeReverseProxy(w, r, true) +} + +func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request) { + s.maybeReverseProxy(w, r, false) +} + +func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) { lg := slog.With( "user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), @@ -233,7 +274,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { switch cr.Rule { case config.RuleAllow: lg.Debug("allowing traffic to origin (explicit)") - s.next.ServeHTTP(w, r) + s.ServeHTTPNext(w, r) return case config.RuleDeny: s.ClearCookie(w) @@ -264,21 +305,21 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { if err != nil { lg.Debug("cookie not found", "path", r.URL.Path) s.ClearCookie(w) - s.RenderIndex(w, r, rule) + s.RenderIndex(w, r, rule, httpStatusOnly) return } if err := ckie.Valid(); err != nil { lg.Debug("cookie is invalid", "err", err) s.ClearCookie(w) - s.RenderIndex(w, r, rule) + 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.RenderIndex(w, r, rule) + s.RenderIndex(w, r, rule, httpStatusOnly) return } @@ -289,14 +330,14 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { if err != nil || !token.Valid { lg.Debug("invalid token", "path", r.URL.Path, "err", err) s.ClearCookie(w) - s.RenderIndex(w, r, rule) + s.RenderIndex(w, r, rule, httpStatusOnly) return } if randomJitter() { r.Header.Add("X-Anubis-Status", "PASS-BRIEF") lg.Debug("cookie is not enrolled into secondary screening") - s.next.ServeHTTP(w, r) + s.ServeHTTPNext(w, r) return } @@ -304,7 +345,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { if !ok { lg.Debug("invalid token claims type", "path", r.URL.Path) s.ClearCookie(w) - s.RenderIndex(w, r, rule) + s.RenderIndex(w, r, rule, httpStatusOnly) return } challenge := s.challengeFor(r, rule.Challenge.Difficulty) @@ -312,7 +353,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { if claims["challenge"] != challenge { lg.Debug("invalid challenge", "path", r.URL.Path) s.ClearCookie(w) - s.RenderIndex(w, r, rule) + s.RenderIndex(w, r, rule, httpStatusOnly) return } @@ -329,16 +370,22 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { lg.Debug("invalid response", "path", r.URL.Path) failedValidations.Inc() s.ClearCookie(w) - s.RenderIndex(w, r, rule) + s.RenderIndex(w, r, rule, httpStatusOnly) return } slog.Debug("all checks passed") r.Header.Add("X-Anubis-Status", "PASS-FULL") - s.next.ServeHTTP(w, r) + s.ServeHTTPNext(w, r) } -func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot) { +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")) + return + } + lg := slog.With( "user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), @@ -470,6 +517,19 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { timeTaken.Observe(elapsedTime) response := r.FormValue("response") + urlParsed, err := r.URL.Parse(redir) + if err != nil { + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect URL not parseable", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + if len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) { + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } else if urlParsed.Host != r.URL.Host { + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } challenge := s.challengeFor(r, rule.Challenge.Difficulty) diff --git a/lib/anubis_test.go b/lib/anubis_test.go index 60b7913..baa92a4 100644 --- a/lib/anubis_test.go +++ b/lib/anubis_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "testing" "github.com/TecharoHQ/anubis" @@ -184,6 +185,7 @@ func TestCookieSettings(t *testing.T) { } if resp.StatusCode != http.StatusFound { + resp.Write(os.Stderr) t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode) } diff --git a/package.json b/package.json index 4041096..afdf705 100644 --- a/package.json +++ b/package.json @@ -23,4 +23,4 @@ "postcss-import-url": "^7.2.0", "postcss-url": "^10.1.3" } -} +} \ No newline at end of file diff --git a/test/k8s/cert-manager/selfsigned-issuer.yaml b/test/k8s/cert-manager/selfsigned-issuer.yaml new file mode 100644 index 0000000..07d2b7b --- /dev/null +++ b/test/k8s/cert-manager/selfsigned-issuer.yaml @@ -0,0 +1,6 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned +spec: + selfSigned: {} \ No newline at end of file diff --git a/test/k8s/deps/cert-manager.yaml b/test/k8s/deps/cert-manager.yaml new file mode 100644 index 0000000..f3e17fa --- /dev/null +++ b/test/k8s/deps/cert-manager.yaml @@ -0,0 +1,13 @@ +apiVersion: helm.cattle.io/v1 +kind: HelmChart +metadata: + name: cert-manager + namespace: kube-system +spec: + repo: https://charts.jetstack.io + chart: cert-manager + targetNamespace: cert-manager + createNamespace: true + set: + installCRDs: "true" + "prometheus.enabled": "false" \ No newline at end of file diff --git a/test/nginx-external-auth/conf.d/default.conf b/test/nginx-external-auth/conf.d/default.conf new file mode 100644 index 0000000..e9e5a78 --- /dev/null +++ b/test/nginx-external-auth/conf.d/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + listen [::]:80; + server_name nginx.local.cetacean.club; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + location /.within.website/ { + proxy_pass http://localhost:8923; + auth_request off; + } + + location @redirectToAnubis { + return 307 /.within.website/?redir=$scheme://$host$request_uri; + auth_request off; + } + + location / { + auth_request /.within.website/x/cmd/anubis/api/check; + error_page 401 = @redirectToAnubis; + root /usr/share/nginx/html; + index index.html index.htm; + } +} \ No newline at end of file diff --git a/test/nginx-external-auth/deployment.yaml b/test/nginx-external-auth/deployment.yaml new file mode 100644 index 0000000..f4b408b --- /dev/null +++ b/test/nginx-external-auth/deployment.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-external-auth +spec: + selector: + matchLabels: + app: nginx-external-auth + template: + metadata: + labels: + app: nginx-external-auth + spec: + volumes: + - name: config + configMap: + name: nginx-cfg + containers: + - name: www + image: nginx:alpine + resources: + limits: + memory: "128Mi" + cpu: "500m" + requests: + memory: "128Mi" + cpu: "500m" + ports: + - containerPort: 80 + volumeMounts: + - name: config + mountPath: /etc/nginx/conf.d + readOnly: true + - name: anubis + image: ttl.sh/techaro/anubis-external-auth:latest + imagePullPolicy: Always + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 250m + memory: 128Mi + env: + - name: TARGET + value: " " + - name: REDIRECT_DOMAINS + value: nginx.local.cetacean.club + + diff --git a/test/nginx-external-auth/ingress.yaml b/test/nginx-external-auth/ingress.yaml new file mode 100644 index 0000000..6fc8737 --- /dev/null +++ b/test/nginx-external-auth/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: nginx-external-auth + labels: + name: nginx-external-auth + annotations: + cert-manager.io/cluster-issuer: "selfsigned" +spec: + ingressClassName: traefik + tls: + - hosts: + - nginx.local.cetacean.club + secretName: nginx-local-cetacean-club-public-tls + rules: + - host: nginx.local.cetacean.club + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: nginx-external-auth + port: + name: http diff --git a/test/nginx-external-auth/kustomization.yaml b/test/nginx-external-auth/kustomization.yaml new file mode 100644 index 0000000..7410f97 --- /dev/null +++ b/test/nginx-external-auth/kustomization.yaml @@ -0,0 +1,10 @@ +resources: + - deployment.yaml + - service.yaml + - ingress.yaml + +configMapGenerator: + - name: nginx-cfg + behavior: create + files: + - ./conf.d/default.conf diff --git a/test/nginx-external-auth/service.yaml b/test/nginx-external-auth/service.yaml new file mode 100644 index 0000000..d2e018c --- /dev/null +++ b/test/nginx-external-auth/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx-external-auth +spec: + selector: + app: nginx-external-auth + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 80 + type: ClusterIP diff --git a/test/nginx-external-auth/start.sh b/test/nginx-external-auth/start.sh new file mode 100755 index 0000000..044238a --- /dev/null +++ b/test/nginx-external-auth/start.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Build container image +( + cd ../.. \ + && npm ci \ + && npm run container -- \ + --docker-repo ttl.sh/techaro/anubis-external-auth \ + --docker-tags ttl.sh/techaro/anubis-external-auth:latest +) + +kubectl apply -k . +echo "open https://nginx.local.cetacean.club, press control c when done" + +control_c() { + kubectl delete -k . + exit +} +trap control_c SIGINT + +sleep infinity \ No newline at end of file diff --git a/test/pki/.gitignore b/test/pki/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/test/pki/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/test/shared/www/index.html b/test/shared/www/index.html new file mode 100644 index 0000000..8c55c8c --- /dev/null +++ b/test/shared/www/index.html @@ -0,0 +1,17 @@ + + + + Anubis works! + + + + +
+

Anubis works!

+ +

If you see this, everything has gone according to keikaku.

+ + +
+ + \ No newline at end of file diff --git a/web/index.templ b/web/index.templ index 818c6a5..4fdb4fc 100644 --- a/web/index.templ +++ b/web/index.templ @@ -126,6 +126,18 @@ templ errorPage(message string, mail string) { } +templ StaticHappy() { +
+ diff --git a/web/index_templ.go b/web/index_templ.go index def54ad..8286000 100644 --- a/web/index_templ.go +++ b/web/index_templ.go @@ -297,7 +297,7 @@ func errorPage(message string, mail string) templ.Component { }) } -func bench() templ.Component { +func StaticHappy() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -318,34 +318,77 @@ func bench() templ.Component { templ_7745c5c3_Var16 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
TimeIters
Time AIters ATime BIters B

Loading...

") + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + + anubis.Version) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 166, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">

Loading...

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }