From 78fe07a78fbdbda915a7bb2ebad70728dd867923 Mon Sep 17 00:00:00 2001 From: Max Chernoff Date: Mon, 16 Mar 2026 03:36:40 -0700 Subject: [PATCH] feat(http): set "Cache-Control: no-store" on error responses (#1474) * refactor(http): split long line in respondWithStatus Signed-off-by: Max Chernoff * feat(http): set `Cache-Control: no-store` on error responses Since #132, Anubis has set `Cache-Control: no-store` on challenge responses. However, this does not apply to deny responses, meaning that if Anubis is configured to block certain user agents and is behind a caching reverse proxy, this error page will be cached and served to all subsequent requests, even those with an allowed user agent. This commit configures the error page responder to also set the `Cache-Control` header, meaning that deny and challenge responses will now both have the same behaviour. Signed-off-by: Max Chernoff * chore(spelling): add new words to allowlist Signed-off-by: Max Chernoff * chore(actions): bump Go version to fix govulncheck errors Signed-off-by: Max Chernoff --------- Signed-off-by: Max Chernoff Signed-off-by: Xe Iaso Co-authored-by: Xe Iaso --- .github/actions/spelling/allow.txt | 3 +++ .github/workflows/asset-verification.yml | 2 +- docs/docs/CHANGELOG.md | 1 + lib/http.go | 9 ++++++- lib/http_test.go | 32 ++++++++++++++++++++++++ lib/testdata/useragent.yaml | 12 +++++++++ 6 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 lib/testdata/useragent.yaml diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 378ca8f5..28a4b092 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -26,3 +26,6 @@ blocklists rififi prolocation Prolocation +Necron +Stargate +FFXIV diff --git a/.github/workflows/asset-verification.yml b/.github/workflows/asset-verification.yml index 4a0286a4..2792cf50 100644 --- a/.github/workflows/asset-verification.yml +++ b/.github/workflows/asset-verification.yml @@ -27,7 +27,7 @@ jobs: node-version: "24.11.0" - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: "1.25.4" + go-version: "1.25.7" - name: install node deps run: | diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 3917ba26..3d39e991 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Instruct reverse proxies to not cache error pages. - Fixed mixed tab/space indentation in Caddy documentation code block diff --git a/lib/http.go b/lib/http.go index 053470f0..790a4aae 100644 --- a/lib/http.go +++ b/lib/http.go @@ -333,7 +333,14 @@ func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, messag func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) { localizer := localization.GetLocalizer(r) - templ.Handler(web.Base(localizer.T("oh_noes"), web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer), s.policy.Impressum, localizer), templ.WithStatus(status)).ServeHTTP(w, r) + component := web.Base( + localizer.T("oh_noes"), + web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer), + s.policy.Impressum, + localizer, + ) + handler := internal.NoStoreCache(templ.Handler(component, templ.WithStatus(status))) + handler.ServeHTTP(w, r) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/lib/http_test.go b/lib/http_test.go index 255344f3..bacc8358 100644 --- a/lib/http_test.go +++ b/lib/http_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/policy" ) @@ -191,3 +192,34 @@ func TestRenderIndexUnauthorized(t *testing.T) { t.Errorf("expected body %q, got %q", "Authorization required", body) } } + +func TestNoCacheOnError(t *testing.T) { + pol := loadPolicies(t, "testdata/useragent.yaml", 0) + srv := spawnAnubis(t, Options{Policy: pol}) + ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) + defer ts.Close() + + for userAgent, expectedCacheControl := range map[string]string{ + "DENY": "no-store", + "CHALLENGE": "no-store", + "ALLOW": "", + } { + t.Run(userAgent, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, ts.URL, nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("User-Agent", userAgent) + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + + if resp.Header.Get("Cache-Control") != expectedCacheControl { + t.Errorf("wanted Cache-Control header %q, got %q", expectedCacheControl, resp.Header.Get("Cache-Control")) + } + }) + } +} diff --git a/lib/testdata/useragent.yaml b/lib/testdata/useragent.yaml new file mode 100644 index 00000000..85cf73e2 --- /dev/null +++ b/lib/testdata/useragent.yaml @@ -0,0 +1,12 @@ +bots: + - name: deny + user_agent_regex: DENY + action: DENY + + - name: challenge + user_agent_regex: CHALLENGE + action: CHALLENGE + + - name: allow + user_agent_regex: ALLOW + action: ALLOW