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 e503a788..8b2c9aa6 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 963d6ca1..c7a19449 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 75ac70fc..c554b918 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 dccf7653..c074f596 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 048260bd..f54c45af 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 0276d7d2..a785f984 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 8cd31277..b9be014e 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 4e71bcf7..c0611a53 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 7209d582..1332035a 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")))