diff --git a/lib/anubis.go b/lib/anubis.go index ac3481ab..d3d3325d 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -13,6 +13,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -97,6 +98,8 @@ type Server struct { OGTags *ogtags.OGTagCache logger *slog.Logger opts Options + basePrefix string + publicURL string ed25519Priv ed25519.PrivateKey hs512Secret []byte } @@ -123,6 +126,50 @@ func (s *Server) getRequestLogger(r *http.Request) (*slog.Logger, *http.Request) return lg, r } +var anubisBasePrefixMu sync.Mutex + +func (s *Server) configuredBasePrefix() string { + if s.basePrefix != "" { + return s.basePrefix + } + return strings.TrimRight(s.opts.BasePrefix, "/") +} + +func (s *Server) configuredPublicURL() string { + if s.publicURL != "" { + return s.publicURL + } + return s.opts.PublicUrl +} + +func (s *Server) prefixedPath(path string) string { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return s.configuredBasePrefix() + path +} + +func (s *Server) cookiePath() string { + basePrefix := s.configuredBasePrefix() + if basePrefix == "" { + return "/" + } + return basePrefix + "/" +} + +func (s *Server) withAnubisBasePrefix(fn func()) { + anubisBasePrefixMu.Lock() + defer anubisBasePrefixMu.Unlock() + + oldBasePrefix := anubis.BasePrefix + anubis.BasePrefix = s.configuredBasePrefix() + defer func() { + anubis.BasePrefix = oldBasePrefix + }() + + fn() +} + func (s *Server) getTokenKeyfunc() jwt.Keyfunc { // return ED25519 key if HS512 is not set if len(s.hs512Secret) == 0 { @@ -246,11 +293,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS return } - // Adjust cookie path if base prefix is not empty - cookiePath := "/" - if anubis.BasePrefix != "" { - cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/" - } + cookiePath := s.cookiePath() cr, rule, err := s.check(r, lg) if err != nil { @@ -344,11 +387,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS } func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool { - // Adjust cookie path if base prefix is not empty - cookiePath := "/" - if anubis.BasePrefix != "" { - cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/" - } + cookiePath := s.cookiePath() localizer := localization.GetLocalizer(r) @@ -511,11 +550,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { return } - // Adjust cookie path if base prefix is not empty - cookiePath := "/" - if anubis.BasePrefix != "" { - cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/" - } + cookiePath := s.cookiePath() if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) { s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host}) diff --git a/lib/anubis_test.go b/lib/anubis_test.go index 3ea07199..8c97696a 100644 --- a/lib/anubis_test.go +++ b/lib/anubis_test.go @@ -2,6 +2,7 @@ package lib import ( "bytes" + "compress/gzip" "context" "encoding/json" "fmt" @@ -501,8 +502,11 @@ func TestBasePrefix(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Reset the global BasePrefix before each test - anubis.BasePrefix = "" + originalBasePrefix := anubis.BasePrefix + anubis.BasePrefix = "/not-this-server" + t.Cleanup(func() { + anubis.BasePrefix = originalBasePrefix + }) pol := loadPolicies(t, "", 4) @@ -631,10 +635,96 @@ func TestBasePrefix(t *testing.T) { if ckie.Path != expectedPath { t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path) } + + if anubis.BasePrefix != "/not-this-server" { + t.Errorf("New should not overwrite anubis.BasePrefix, got %q", anubis.BasePrefix) + } }) } } +func TestBasePrefixUsesServerConfigNotPackageGlobal(t *testing.T) { + originalBasePrefix := anubis.BasePrefix + anubis.BasePrefix = "/global" + t.Cleanup(func() { + anubis.BasePrefix = originalBasePrefix + }) + + pol := loadPolicies(t, "", 4) + srv := spawnAnubis(t, Options{ + Next: http.NewServeMux(), + Policy: pol, + BasePrefix: "/local/", + }) + + if anubis.BasePrefix != "/global" { + t.Fatalf("New should leave anubis.BasePrefix alone, got %q", anubis.BasePrefix) + } + + req := httptest.NewRequest(http.MethodPost, "/local/.within.website/x/cmd/anubis/api/make-challenge?redir=/", nil) + req.Header.Set("X-Real-Ip", "127.0.0.1") + rr := httptest.NewRecorder() + + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected local server prefix route to work, got status %d body %q", rr.Code, rr.Body.String()) + } +} + +func TestRenderIndexUsesServerBasePrefixNotPackageGlobal(t *testing.T) { + originalBasePrefix := anubis.BasePrefix + anubis.BasePrefix = "/global" + t.Cleanup(func() { + anubis.BasePrefix = originalBasePrefix + }) + + pol := loadPolicies(t, "", 4) + srv := spawnAnubis(t, Options{ + Next: http.NewServeMux(), + Policy: pol, + BasePrefix: "/local/", + }) + + req := httptest.NewRequest(http.MethodGet, "/local/protected", nil) + req.Header.Set("X-Real-Ip", "127.0.0.1") + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + + srv.ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + + var body strings.Builder + reader := io.Reader(resp.Body) + if resp.Header.Get("Content-Encoding") == "gzip" { + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + t.Fatalf("opening gzip response should not fail: %v", err) + } + defer gzipReader.Close() + reader = gzipReader + } + if _, err := io.Copy(&body, reader); err != nil { + t.Fatalf("reading challenge response should not fail: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected challenge status %d, got %d body %q", http.StatusOK, resp.StatusCode, body.String()) + } + if !strings.Contains(body.String(), "/local/.within.website/x/cmd/anubis/static/js/main.mjs") { + t.Fatalf("expected challenge assets to use server base prefix, body %q", body.String()) + } + if strings.Contains(body.String(), "/global/.within.website") { + t.Fatalf("challenge body used package global base prefix: %q", body.String()) + } + if anubis.BasePrefix != "/global" { + t.Fatalf("rendering should restore anubis.BasePrefix, got %q", anubis.BasePrefix) + } +} + func TestCustomStatusCodes(t *testing.T) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Log(r.UserAgent()) diff --git a/lib/config.go b/lib/config.go index f577ecf7..9b719fb6 100644 --- a/lib/config.go +++ b/lib/config.go @@ -112,8 +112,7 @@ func New(opts Options) (*Server, error) { opts.ED25519PrivateKey = priv } - anubis.BasePrefix = strings.TrimRight(opts.BasePrefix, "/") - anubis.PublicUrl = opts.PublicUrl + opts.BasePrefix = strings.TrimRight(opts.BasePrefix, "/") result := &Server{ next: opts.Next, @@ -121,6 +120,8 @@ func New(opts Options) (*Server, error) { hs512Secret: opts.HS512Secret, policy: opts.Policy, opts: opts, + basePrefix: opts.BasePrefix, + publicURL: opts.PublicUrl, OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{ Host: opts.TargetHost, SNI: opts.TargetSNI, @@ -131,28 +132,20 @@ func New(opts Options) (*Server, error) { } mux := http.NewServeMux() - xess.Mount(mux) + xessPrefix := result.prefixedPath(xess.BasePrefix) + mux.Handle(xessPrefix, internal.UnchangingCache(http.StripPrefix(xessPrefix, http.FileServerFS(xess.Static)))) - // Helper to add global prefix + // Helper to add the server-local base prefix. registerWithPrefix := func(pattern string, handler http.Handler, method string) { if method != "" { method = method + " " // methods must end with a space to register with them } - // Ensure there's no double slash when concatenating BasePrefix and pattern - basePrefix := strings.TrimSuffix(anubis.BasePrefix, "/") - prefix := method + basePrefix - - // If pattern doesn't start with a slash, add one - if !strings.HasPrefix(pattern, "/") { - pattern = "/" + pattern - } - - mux.Handle(prefix+pattern, handler) + mux.Handle(method+result.prefixedPath(pattern), handler) } // Ensure there's no double slash when concatenating BasePrefix and StaticPath - stripPrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + anubis.StaticPath + stripPrefix := result.prefixedPath(anubis.StaticPath) registerWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "") if opts.ServeRobotsTXT { @@ -166,9 +159,12 @@ func New(opts Options) (*Server, error) { if opts.Policy.Impressum != nil { registerWithPrefix(anubis.APIPrefix+"imprint", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - templ.Handler( + handler := templ.Handler( web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)), - ).ServeHTTP(w, r) + ) + result.withAnubisBasePrefix(func() { + handler.ServeHTTP(w, r) + }) }), "GET") } diff --git a/lib/http.go b/lib/http.go index 0276282c..aad658d6 100644 --- a/lib/http.go +++ b/lib/http.go @@ -193,7 +193,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C localizer := localization.GetLocalizer(r) if returnHTTPStatusOnly { - if s.opts.PublicUrl == "" { + if s.configuredPublicURL() == "" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(localizer.T("authorization_required"))) } else { @@ -270,7 +270,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C Store: s.store, } - component, err := impl.Issue(w, r, lg, in) + var component templ.Component + s.withAnubisBasePrefix(func() { + component, err = impl.Issue(w, r, lg, in) + }) if err != nil { lg.Error("[unexpected] challenge component render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this. s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")), makeCode(err)) @@ -291,7 +294,9 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C page, templ.WithStatus(s.opts.Policy.StatusCodes.Challenge), ))) - handler.ServeHTTP(w, r) + s.withAnubisBasePrefix(func() { + handler.ServeHTTP(w, r) + }) } func (s *Server) constructRedirectURL(r *http.Request) (string, error) { @@ -323,15 +328,18 @@ func (s *Server) constructRedirectURL(r *http.Request) (string, error) { redir := proto + "://" + host + uri escapedURL := url.QueryEscape(redir) - return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil + return fmt.Sprintf("%s/.within.website/?redir=%s", s.configuredPublicURL(), escapedURL), nil } func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) { localizer := localization.GetLocalizer(r) - templ.Handler( + handler := templ.Handler( web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer), - ).ServeHTTP(w, r) + ) + s.withAnubisBasePrefix(func() { + handler.ServeHTTP(w, r) + }) } func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) { @@ -348,21 +356,31 @@ func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, localizer, ) handler := internal.NoStoreCache(templ.Handler(component, templ.WithStatus(status))) - handler.ServeHTTP(w, r) + s.withAnubisBasePrefix(func() { + handler.ServeHTTP(w, r) + }) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+anubis.StaticPath) { + if strings.HasPrefix(r.URL.Path, s.prefixedPath(anubis.StaticPath)) { s.mux.ServeHTTP(w, r) return - } else if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+xess.BasePrefix) { + } else if strings.HasPrefix(r.URL.Path, s.prefixedPath(xess.BasePrefix)) { s.mux.ServeHTTP(w, r) return } // Forward robots.txt requests to mux when ServeRobotsTXT is enabled if s.opts.ServeRobotsTXT { - path := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix) + path := r.URL.Path + basePrefix := s.configuredBasePrefix() + if basePrefix != "" { + if !strings.HasPrefix(path, basePrefix) { + s.maybeReverseProxyOrPage(w, r) + return + } + path = strings.TrimPrefix(path, basePrefix) + } if path == "/robots.txt" || path == "/.well-known/robots.txt" { s.mux.ServeHTTP(w, r) return @@ -373,11 +391,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request { - if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" { + basePrefix := s.configuredBasePrefix() + if !s.opts.StripBasePrefix || basePrefix == "" { return r } - basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/") path := r.URL.Path if !strings.HasPrefix(path, basePrefix) { @@ -441,9 +459,12 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) { return } - templ.Handler( + handler := templ.Handler( web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer), - ).ServeHTTP(w, r) + ) + s.withAnubisBasePrefix(func() { + handler.ServeHTTP(w, r) + }) } else { asn, asnDesc := asnFromContext(r.Context()) requestsProxied.WithLabelValues(r.Host, asn, asnDesc).Inc()