Compare commits

...

2 Commits

Author SHA1 Message Date
Jason Cameron dd6b44df90 fix: nil ptr deref
Signed-off-by: Jason Cameron <jason.cameron@stanwith.me>
2026-02-18 14:18:46 -05:00
Xe Iaso 35b5e78a0d chore: tag v1.25.0
Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-02-18 15:56:28 +00:00
12 changed files with 175 additions and 12 deletions
+1 -1
View File
@@ -1 +1 @@
1.24.0
1.25.0
+28 -2
View File
@@ -11,6 +11,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)
<!-- This changes the project to: -->
## v1.25.0: Necron
Hey all,
I'm sure you've all been aware that things have been slowing down a little with Anubis development, and I want to apologize for that. A lot has been going on in my life lately (my blog will have a post out on Friday with more information), and as a result I haven't really had the energy to work on Anubis in publicly visible ways. There are things going on behind the scenes, but nothing is really shippable yet, sorry!
I've also been feeling some burnout in the wake of perennial waves of anger directed towards me. I'm handling it, I'll be fine, I've just had a lot going on in my life and it's been rough.
I've been missing the sense of wanderlust and discovery that comes with the artistic way I playfully develop software. I suspect that some of the stresses I've been through (setting up a complicated surgery in a country whose language you aren't fluent in is kind of an experience) have been sapping my energy. I'd gonna try to mess with things on my break, but realistically I'm probably just gonna be either watching Stargate SG-1 or doing unreasonable amounts of ocean fishing in Final Fantasy 14. Normally I'd love to keep the details about my medical state fairly private, but I'm more of a public figure now than I was this time last year so I don't really get the invisibility I'm used to for this.
I've also had a fair amount of negativity directed at me for simply being much more visible than the anonymous threat actors running the scrapers that are ruining everything, which though understandable has not helped.
Anyways, it all worked out and I'm about to be in the hospital for a week, so if things go really badly with this release please downgrade to the last version and/or upgrade to the main branch when the fix PR is inevitably merged. I hoped to have time to tame GPG and set up full release automation in the Anubis repo, but that didn't work out this time and that's okay.
If I can challenge you all to do something, go out there and try to actually create something new somehow. Combine ideas you've never mixed before. Be creative, be human, make something purely for yourself to scratch an itch that you've always had yet never gotten around to actually mending.
At the very least, try to be an example of how you want other people to act, even when you're in a situation where software written by someone else is configured to require a user agent to execute javascript to access a webpage.
Be well,
Xe
PS: if you're well-versed in FFXIV lore, the release title should give you an idea of the kind of stuff I've been going through mentally.
- Add iplist2rule tool that lets admins turn an IP address blocklist into an Anubis ruleset.
- Add Polish locale ([#1292](https://github.com/TecharoHQ/anubis/pull/1309))
- Fix honeypot and imprint links missing `BASE_PREFIX` when deployed behind a path prefix ([#1402](https://github.com/TecharoHQ/anubis/issues/1402))
@@ -18,8 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve idle performance in memory storage
- Add HAProxy Configurations to Docs ([#1424](https://github.com/TecharoHQ/anubis/pull/1424))
<!-- This changes the project to: -->
## v1.24.0: Y'shtola Rhul
Anubis is back and better than ever! Lots of minor fixes with some big ones interspersed.
+21 -2
View File
@@ -106,6 +106,13 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
//return nil, errors.New("[unexpected] this codepath should be impossible, asked to issue a challenge for a non-challenge rule")
}
if rule.Challenge == nil {
rule.Challenge = &config.ChallengeRules{
Difficulty: s.policy.DefaultDifficulty,
Algorithm: config.DefaultAlgorithm,
}
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
@@ -491,7 +498,11 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
chall, err := s.getChallenge(r)
if err != nil {
lg.Error("getChallenge failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
algorithm := "unknown"
if rule.Challenge != nil {
algorithm = rule.Challenge.Algorithm
}
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err))
return
}
@@ -638,8 +649,16 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
}
if matches {
challRules := t.Challenge
if challRules == nil {
// Non-CHALLENGE thresholds (ALLOW/DENY) don't have challenge config.
// Use an empty struct so hydrateChallengeRule can fill from stored
// challenge data during validation, rather than baking in defaults
// that could mismatch the difficulty the client actually solved for.
challRules = &config.ChallengeRules{}
}
return cr("threshold/"+t.Name, t.Action, weight), &policy.Bot{
Challenge: t.Challenge,
Challenge: challRules,
Rules: &checker.List{},
}, nil
}
+1
View File
@@ -10,6 +10,7 @@ var (
ErrFailed = errors.New("challenge: user failed challenge")
ErrMissingField = errors.New("challenge: missing field")
ErrInvalidFormat = errors.New("challenge: field has invalid format")
ErrInvalidInput = errors.New("challenge: input is nil or missing required fields")
)
func NewError(verb, publicReason string, privateReason error) *Error {
+33
View File
@@ -1,6 +1,7 @@
package challenge
import (
"fmt"
"log/slog"
"net/http"
"sort"
@@ -50,12 +51,44 @@ type IssueInput struct {
Store store.Interface
}
func (in *IssueInput) Valid() error {
if in == nil {
return fmt.Errorf("%w: IssueInput is nil", ErrInvalidInput)
}
if in.Rule == nil {
return fmt.Errorf("%w: Rule is nil", ErrInvalidInput)
}
if in.Rule.Challenge == nil {
return fmt.Errorf("%w: Rule.Challenge is nil", ErrInvalidInput)
}
if in.Challenge == nil {
return fmt.Errorf("%w: Challenge is nil", ErrInvalidInput)
}
return nil
}
type ValidateInput struct {
Rule *policy.Bot
Challenge *Challenge
Store store.Interface
}
func (in *ValidateInput) Valid() error {
if in == nil {
return fmt.Errorf("%w: ValidateInput is nil", ErrInvalidInput)
}
if in.Rule == nil {
return fmt.Errorf("%w: Rule is nil", ErrInvalidInput)
}
if in.Rule.Challenge == nil {
return fmt.Errorf("%w: Rule.Challenge is nil", ErrInvalidInput)
}
if in.Challenge == nil {
return fmt.Errorf("%w: Challenge is nil", ErrInvalidInput)
}
return nil
}
type Impl interface {
// Setup registers any additional routes with the Impl for assets or API routes.
Setup(mux *http.ServeMux)
+8
View File
@@ -24,6 +24,10 @@ type Impl struct{}
func (i *Impl) Setup(mux *http.ServeMux) {}
func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
if err := in.Valid(); err != nil {
return nil, err
}
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)
@@ -49,6 +53,10 @@ func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
}
func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
if err := in.Valid(); err != nil {
return challenge.NewError("validate", "invalid input", err)
}
wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 800 * time.Millisecond)
if time.Now().Before(wantTime) {
+8
View File
@@ -39,6 +39,10 @@ type impl struct{}
func (i *impl) Setup(mux *http.ServeMux) {}
func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
if err := in.Valid(); err != nil {
return nil, err
}
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)
@@ -57,6 +61,10 @@ func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
}
func (i *impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
if err := in.Valid(); err != nil {
return challenge.NewError("validate", "invalid input", err)
}
wantTime := in.Challenge.IssuedAt.Add(time.Duration(in.Rule.Challenge.Difficulty) * 80 * time.Millisecond)
if time.Now().Before(wantTime) {
+4
View File
@@ -33,6 +33,10 @@ func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in
}
func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error {
if err := in.Valid(); err != nil {
return chall.NewError("validate", "invalid input", err)
}
rule := in.Rule
challenge := in.Challenge.RandomData
@@ -30,6 +30,62 @@ func mkRequest(t *testing.T, values map[string]string) *http.Request {
return req
}
// TestValidateNilRuleChallenge reproduces the panic from
// https://github.com/TecharoHQ/anubis/issues/1463
//
// When a threshold rule matches during PassChallenge, check() can return
// a policy.Bot with Challenge == nil. After hydrateChallengeRule fails to
// run (or the error path hits before it), Validate dereferences
// rule.Challenge.Difficulty and panics.
func TestValidateNilRuleChallenge(t *testing.T) {
i := &Impl{Algorithm: "fast"}
lg := slog.With()
// This is the exact response for SHA256("hunter" + "0") with 0 leading zeros required.
const challengeStr = "hunter"
const response = "2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e"
req := mkRequest(t, map[string]string{
"nonce": "0",
"elapsedTime": "69",
"response": response,
})
for _, tc := range []struct {
name string
input *challenge.ValidateInput
}{
{
name: "nil-rule-challenge",
input: &challenge.ValidateInput{
Rule: &policy.Bot{},
Challenge: &challenge.Challenge{RandomData: challengeStr},
},
},
{
name: "nil-rule",
input: &challenge.ValidateInput{
Challenge: &challenge.Challenge{RandomData: challengeStr},
},
},
{
name: "nil-challenge",
input: &challenge.ValidateInput{Rule: &policy.Bot{Challenge: &config.ChallengeRules{Algorithm: "fast"}}},
},
{
name: "nil-input",
input: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
err := i.Validate(req, lg, tc.input)
if !errors.Is(err, challenge.ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got: %v", err)
}
})
}
}
func TestBasic(t *testing.T) {
i := &Impl{Algorithm: "fast"}
bot := &policy.Bot{
+11 -3
View File
@@ -222,8 +222,12 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
if err != nil {
lg.Error("can't get challenge", "err", err)
algorithm := "unknown"
if rule.Challenge != nil {
algorithm = rule.Challenge.Algorithm
}
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err))
return
}
@@ -248,9 +252,13 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
impl, ok := challenge.Get(chall.Method)
if !ok {
lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
algorithm := "unknown"
if rule.Challenge != nil {
algorithm = rule.Challenge.Algorithm
}
lg.Error("check failed", "err", "can't get algorithm", "algorithm", algorithm)
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), algorithm), makeCode(err))
return
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@techaro/anubis",
"version": "1.24.0",
"version": "1.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@techaro/anubis",
"version": "1.24.0",
"version": "1.25.0",
"license": "ISC",
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@techaro/anubis",
"version": "1.24.0",
"version": "1.25.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -66,4 +66,4 @@
"trailingComma": "all",
"printWidth": 80
}
}
}