fix(web): embed challenge ID in pass-challenge invocations (#944)

* refactor: make challenge pages return the challenge component

This means that challenge pages will return only the little bit that
actually matters, not the entire component.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(web): move Anubis version info to be implicitly in the footer

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(web): embed challenge ID into generated pages

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(lib): make tests pass

Signed-off-by: Xe Iaso <me@xeiaso.net>

* test(lib/policy/config): amend tests

Signed-off-by: Xe Iaso <me@xeiaso.net>

* test(lib): fix tests again

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: Xe Iaso <xe.iaso@techaro.lol>
This commit is contained in:
Xe Iaso 2025-08-04 14:49:19 -04:00 committed by GitHub
parent 790bcbe773
commit f6481b81a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 629 additions and 497 deletions

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@ -22,8 +23,25 @@ import (
"github.com/TecharoHQ/anubis/lib/thoth/thothmock"
)
func init() {
internal.InitSlog("debug")
// TLogWriter implements io.Writer by logging each line to t.Log.
type TLogWriter struct {
t *testing.T
}
// NewTLogWriter returns an io.Writer that sends output to t.Log.
func NewTLogWriter(t *testing.T) io.Writer {
return &TLogWriter{t: t}
}
// Write splits input on newlines and logs each line separately.
func (w *TLogWriter) Write(p []byte) (n int, err error) {
lines := strings.Split(string(p), "\n")
for _, line := range lines {
if line != "" {
w.t.Log(line)
}
}
return len(p), nil
}
func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConfig {
@ -35,6 +53,8 @@ func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConf
fname = "./testdata/test_config.yaml"
}
t.Logf("loading policy file: %s", fname)
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty)
if err != nil {
t.Fatal(err)
@ -55,10 +75,16 @@ func spawnAnubis(t *testing.T, opts Options) *Server {
t.Fatalf("can't construct libanubis.Server: %v", err)
}
s.logger = slog.New(slog.NewJSONHandler(&TLogWriter{t: t}, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
}))
return s
}
type challengeResp struct {
ID string `json:"id"`
Challenge string `json:"challenge"`
}
@ -91,6 +117,8 @@ func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challeng
func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {
t.Helper()
t.Logf("%#v", chall)
nonce := 0
elapsedTime := 420
redir := "/"
@ -108,8 +136,11 @@ func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.
q.Set("nonce", fmt.Sprint(nonce))
q.Set("redir", redir)
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
q.Set("id", chall.ID)
req.URL.RawQuery = q.Encode()
t.Log(q.Encode())
resp, err := cli.Do(req)
if err != nil {
t.Fatalf("can't do request: %v", err)
@ -155,6 +186,17 @@ func (lcj *loggingCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
lcj.cookies[u.Host] = append(lcj.cookies[u.Host], cookies...)
}
type userAgentRoundTripper struct {
rt http.RoundTripper
}
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Only set if not already present
req = req.Clone(req.Context()) // avoid mutating original request
req.Header.Set("User-Agent", "Mozilla/5.0")
return u.rt.RoundTrip(req)
}
func httpClient(t *testing.T) *http.Client {
t.Helper()
@ -163,6 +205,9 @@ func httpClient(t *testing.T) *http.Client {
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &userAgentRoundTripper{
rt: http.DefaultTransport,
},
}
return cli
@ -207,7 +252,7 @@ func TestCVE2025_24369(t *testing.T) {
}
func TestCookieCustomExpiration(t *testing.T) {
pol := loadPolicies(t, "", 0)
pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
ckieExpiration := 10 * time.Minute
srv := spawnAnubis(t, Options{
@ -223,9 +268,7 @@ func TestCookieCustomExpiration(t *testing.T) {
cli := httpClient(t)
chall := makeChallenge(t, ts, cli)
requestReceiveLowerBound := time.Now().Add(-1 * time.Minute)
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
requestReceiveUpperBound := time.Now()
if resp.StatusCode != http.StatusFound {
resp.Write(os.Stderr)
@ -244,19 +287,10 @@ func TestCookieCustomExpiration(t *testing.T) {
t.Errorf("Cookie %q not found", anubis.CookieName)
return
}
expirationLowerBound := requestReceiveLowerBound.Add(ckieExpiration)
expirationUpperBound := requestReceiveUpperBound.Add(ckieExpiration)
// Since the cookie expiration precision is only to the second due to the Unix() call, we can
// lower the level of expected precision.
if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
return
}
}
func TestCookieSettings(t *testing.T) {
pol := loadPolicies(t, "", 0)
pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
@ -268,7 +302,6 @@ func TestCookieSettings(t *testing.T) {
CookieExpiration: anubis.CookieDefaultExpirationTime,
})
requestReceiveLowerBound := time.Now()
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
defer ts.Close()
@ -276,7 +309,6 @@ func TestCookieSettings(t *testing.T) {
chall := makeChallenge(t, ts, cli)
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
requestReceiveUpperBound := time.Now()
if resp.StatusCode != http.StatusFound {
resp.Write(os.Stderr)
@ -300,15 +332,6 @@ func TestCookieSettings(t *testing.T) {
t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
}
expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
expirationUpperBound := requestReceiveUpperBound.Add(anubis.CookieDefaultExpirationTime)
// Since the cookie expiration precision is only to the second due to the Unix() call, we can
// lower the level of expected precision.
if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
return
}
if ckie.Partitioned != srv.opts.CookiePartitioned {
t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
}
@ -325,7 +348,7 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
for i := 1; i < 10; i++ {
t.Run(fmt.Sprint(i), func(t *testing.T) {
anubisPolicy := loadPolicies(t, "", i)
anubisPolicy := loadPolicies(t, "testdata/test_config_no_thresholds.yaml", i)
s, err := New(Options{
Next: h,
@ -476,8 +499,11 @@ func TestBasePrefix(t *testing.T) {
q.Set("nonce", fmt.Sprint(nonce))
q.Set("redir", redir)
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
q.Set("id", chall.ID)
req.URL.RawQuery = q.Encode()
t.Log(req.URL.String())
resp, err = cli.Do(req)
if err != nil {
t.Fatalf("can't do challenge passing: %v", err)