From fb3637df9553acd391710ae442d34455a4f6ff0d Mon Sep 17 00:00:00 2001
From: Xe Iaso
Date: Tue, 16 Sep 2025 17:32:13 -0400
Subject: [PATCH] feat(metarefresh): randomly use the Refresh header (#1133)
* feat(lib/challenge): expose ResponseWriter to challenge issuers
Signed-off-by: Xe Iaso
* feat(metarefresh): randomly use the Refresh header
There are several ways to trigger an automatic refresh without
JavaScript. One of them is the "meta refresh" method[1], but the other
is with the Refresh header[2]. Both are semantically identical and
supported with browsers as old as Chrome version 1.
Given that they are basically the same thing, this patch makes Anubis
randomly select between them by using the challenge random data's first
character. This will fire about 50% of the time.
I expect this to have no impact. If this works out fine, then I will
implement some kind of fallback logic for the fast challenge such that
admins can opt into allowing clients with a no-js configuration to pass
the fast challenge. This needs to bake in the oven though.
[1]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/http-equiv
[2]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh
Signed-off-by: Xe Iaso
* docs: update CHANGELOG
Signed-off-by: Xe Iaso
* feat(metarefresh): simplify random logic
Signed-off-by: Xe Iaso
---------
Signed-off-by: Xe Iaso
Signed-off-by: Xe Iaso
---
docs/docs/CHANGELOG.md | 1 +
lib/challenge/interface.go | 2 +-
lib/challenge/metarefresh/metarefresh.go | 10 ++++--
lib/challenge/metarefresh/metarefresh.templ | 6 ++--
.../metarefresh/metarefresh_templ.go | 32 ++++++++++++-------
lib/challenge/preact/preact.go | 2 +-
lib/challenge/proofofwork/proofofwork.go | 2 +-
lib/challenge/proofofwork/proofofwork_test.go | 3 +-
lib/http.go | 28 ++++++++--------
9 files changed, 53 insertions(+), 33 deletions(-)
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index e503a78..8b2c9aa 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add the `DIFFICULTY_IN_JWT` option, which allows one to add the `difficulty` field in the JWT claims which indicates the difficulty of the token ([#1063](https://github.com/TecharoHQ/anubis/pull/1063)).
- Ported the client-side JS to TypeScript to avoid egregious errors in the future.
- Fixes concurrency problems with very old browsers ([#1082](https://github.com/TecharoHQ/anubis/issues/1082)).
+- Randomly use the Refresh header instead of the meta refresh tag in the metarefresh challenge.
- Update OpenRC service to truncate the runtime directory before starting Anubis.
### Bug Fixes
diff --git a/lib/challenge/interface.go b/lib/challenge/interface.go
index 963d6ca..c7a1944 100644
--- a/lib/challenge/interface.go
+++ b/lib/challenge/interface.go
@@ -61,7 +61,7 @@ type Impl interface {
Setup(mux *http.ServeMux)
// Issue a new challenge to the user, called by the Anubis.
- Issue(r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error)
+ Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error)
// Validate a challenge, making sure that it passes muster.
Validate(r *http.Request, lg *slog.Logger, in *ValidateInput) error
diff --git a/lib/challenge/metarefresh/metarefresh.go b/lib/challenge/metarefresh/metarefresh.go
index 75ac70f..c554b91 100644
--- a/lib/challenge/metarefresh/metarefresh.go
+++ b/lib/challenge/metarefresh/metarefresh.go
@@ -23,7 +23,7 @@ type Impl struct{}
func (i *Impl) Setup(mux *http.ServeMux) {}
-func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
+func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
@@ -35,9 +35,15 @@ func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput)
q.Set("id", in.Challenge.ID)
u.RawQuery = q.Encode()
+ showMeta := in.Challenge.RandomData[0]%2 == 0
+
+ if !showMeta {
+ w.Header().Add("Refresh", fmt.Sprintf("%d; url=%s", in.Rule.Challenge.Difficulty+1, u.String()))
+ }
+
loc := localization.GetLocalizer(r)
- result := page(u.String(), in.Rule.Challenge.Difficulty, loc)
+ result := page(u.String(), in.Rule.Challenge.Difficulty, showMeta, loc)
return result, nil
}
diff --git a/lib/challenge/metarefresh/metarefresh.templ b/lib/challenge/metarefresh/metarefresh.templ
index dccf765..c074f59 100644
--- a/lib/challenge/metarefresh/metarefresh.templ
+++ b/lib/challenge/metarefresh/metarefresh.templ
@@ -7,12 +7,14 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
-templ page(redir string, difficulty int, loc *localization.SimpleLocalizer) {
+templ page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) {
{ loc.T("loading") }
{ loc.T("connection_security") }
-
+ if showMeta {
+
+ }
}
diff --git a/lib/challenge/metarefresh/metarefresh_templ.go b/lib/challenge/metarefresh/metarefresh_templ.go
index 048260b..f54c45a 100644
--- a/lib/challenge/metarefresh/metarefresh_templ.go
+++ b/lib/challenge/metarefresh/metarefresh_templ.go
@@ -15,7 +15,7 @@ import (
"github.com/TecharoHQ/anubis/lib/localization"
)
-func page(redir string, difficulty int, loc *localization.SimpleLocalizer) templ.Component {
+func page(redir string, difficulty int, showMeta bool, loc *localization.SimpleLocalizer) 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 {
@@ -88,20 +88,30 @@ func page(redir string, difficulty int, loc *localization.SimpleLocalizer) templ
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var6 string
- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d; url=%s", difficulty+1, redir))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 16, Col: 85}
+ if showMeta {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/lib/challenge/preact/preact.go b/lib/challenge/preact/preact.go
index 0276d7d..a785f98 100644
--- a/lib/challenge/preact/preact.go
+++ b/lib/challenge/preact/preact.go
@@ -38,7 +38,7 @@ type impl struct{}
func (i *impl) Setup(mux *http.ServeMux) {}
-func (i *impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
+func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
diff --git a/lib/challenge/proofofwork/proofofwork.go b/lib/challenge/proofofwork/proofofwork.go
index 8cd3127..b9be014 100644
--- a/lib/challenge/proofofwork/proofofwork.go
+++ b/lib/challenge/proofofwork/proofofwork.go
@@ -27,7 +27,7 @@ type Impl struct {
func (i *Impl) Setup(mux *http.ServeMux) {}
-func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
+func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
loc := localization.GetLocalizer(r)
return page(loc), nil
}
diff --git a/lib/challenge/proofofwork/proofofwork_test.go b/lib/challenge/proofofwork/proofofwork_test.go
index 4e71bcf..c0611a5 100644
--- a/lib/challenge/proofofwork/proofofwork_test.go
+++ b/lib/challenge/proofofwork/proofofwork_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"log/slog"
"net/http"
+ "net/http/httptest"
"testing"
"github.com/TecharoHQ/anubis/lib/challenge"
@@ -133,7 +134,7 @@ func TestBasic(t *testing.T) {
},
}
- if _, err := i.Issue(cs.req, lg, inp); err != nil {
+ if _, err := i.Issue(httptest.NewRecorder(), cs.req, lg, inp); err != nil {
t.Errorf("can't issue challenge: %v", err)
}
diff --git a/lib/http.go b/lib/http.go
index 7209d58..1332035 100644
--- a/lib/http.go
+++ b/lib/http.go
@@ -29,19 +29,19 @@ var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[
// internal glob matcher. Matching is case-insensitive on hostnames.
func matchRedirectDomain(allowed []string, host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
- for _, pat := range allowed {
- p := strings.ToLower(strings.TrimSpace(pat))
- if strings.Contains(p, glob.GLOB) {
- if glob.Glob(p, h) {
- return true
- }
- continue
- }
- if p == h {
- return true
- }
- }
- return false
+ for _, pat := range allowed {
+ p := strings.ToLower(strings.TrimSpace(pat))
+ if strings.Contains(p, glob.GLOB) {
+ if glob.Glob(p, h) {
+ return true
+ }
+ continue
+ }
+ if p == h {
+ return true
+ }
+ }
+ return false
}
type CookieOpts struct {
@@ -214,7 +214,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
Store: s.store,
}
- component, err := impl.Issue(r, lg, in)
+ 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")))