Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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)
- Instruct reverse proxies to not cache error pages.
- Fixed mixed tab/space indentation in Caddy documentation code block

Expand Down
23 changes: 21 additions & 2 deletions lib/anubis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions lib/challenge/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions lib/challenge/interface.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package challenge

import (
"fmt"
"log/slog"
"net/http"
"sort"
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions lib/challenge/metarefresh/metarefresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions lib/challenge/preact/preact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions lib/challenge/proofofwork/proofofwork.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions lib/challenge/proofofwork/proofofwork_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
14 changes: 11 additions & 3 deletions lib/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,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
}

Expand All @@ -245,9 +249,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
}

Expand Down
Loading