mirror of
https://github.com/TecharoHQ/anubis.git
synced 2025-08-03 09:48:08 -04:00
feat(lib): ensure that clients store cookies (#501)
* feat(lib): ensure that clients store cookies If a client is misconfigured and does not store cookies, then they can get into a proof of work death spiral with Anubis. This fixes the problem by setting a test cookie whenever the user gets hit with a challenge page. If the test cookie is not there at challenge pass time, then they are blocked. Administrators will also get a log message explaining that the user intentionally broke cookie support and that this behavior is not an Anubis bug. Additionally, this ensures that clients being shown a challenge support gzip-compressed responses by showing the challenge page at gzip level 1. This level is intentionally chosen in order to minimize system impacts. The ClearCookie function is made more generic to account for cookie names as an argument. A correlating SetCookie function was also added to make it easier to set cookies. * chore(lib): clean up test code Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
parent
9e9982ab5d
commit
b640c567da
1
.github/actions/spelling/expect.txt
vendored
1
.github/actions/spelling/expect.txt
vendored
@ -81,6 +81,7 @@ goodbot
|
|||||||
googlebot
|
googlebot
|
||||||
govulncheck
|
govulncheck
|
||||||
GPG
|
GPG
|
||||||
|
grw
|
||||||
Hashcash
|
Hashcash
|
||||||
hashrate
|
hashrate
|
||||||
headermap
|
headermap
|
||||||
|
@ -16,6 +16,8 @@ const CookieName = "techaro.lol-anubis-auth"
|
|||||||
// WithDomainCookieName is the name that is prepended to the per-domain cookie used when COOKIE_DOMAIN is set.
|
// WithDomainCookieName is the name that is prepended to the per-domain cookie used when COOKIE_DOMAIN is set.
|
||||||
const WithDomainCookieName = "techaro.lol-anubis-auth-for-"
|
const WithDomainCookieName = "techaro.lol-anubis-auth-for-"
|
||||||
|
|
||||||
|
const TestCookieName = "techaro.lol-anubis-cookie-test-if-you-block-this-anubis-wont-work"
|
||||||
|
|
||||||
// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.
|
// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.
|
||||||
const CookieDefaultExpirationTime = 7 * 24 * time.Hour
|
const CookieDefaultExpirationTime = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Ensure that clients that are shown a challenge support storing cookies
|
||||||
|
- Encode challenge pages with gzip level 1
|
||||||
- Add `check-spelling` for spell checking
|
- Add `check-spelling` for spell checking
|
||||||
- Add `--target-insecure-skip-verify` flag/envvar to allow Anubis to hit a self-signed HTTPS backend
|
- Add `--target-insecure-skip-verify` flag/envvar to allow Anubis to hit a self-signed HTTPS backend
|
||||||
- Minor adjustments to FreeBSD rc.d script to allow for more flexible configuration.
|
- Minor adjustments to FreeBSD rc.d script to allow for more flexible configuration.
|
||||||
|
35
internal/gzip.go
Normal file
35
internal/gzip.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GzipMiddleware(level int, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
gz, err := gzip.NewWriterLevel(w, level)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
grw := gzipResponseWriter{ResponseWriter: w, sink: gz}
|
||||||
|
next.ServeHTTP(grw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type gzipResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
sink *gzip.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w gzipResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
return w.sink.Write(b)
|
||||||
|
}
|
@ -121,21 +121,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
|
|||||||
ckie, err := r.Cookie(s.cookieName)
|
ckie, err := r.Cookie(s.cookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Debug("cookie not found", "path", r.URL.Path)
|
lg.Debug("cookie not found", "path", r.URL.Path)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ckie.Valid(); err != nil {
|
if err := ckie.Valid(); err != nil {
|
||||||
lg.Debug("cookie is invalid", "err", err)
|
lg.Debug("cookie is invalid", "err", err)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
||||||
lg.Debug("cookie expired", "path", r.URL.Path)
|
lg.Debug("cookie expired", "path", r.URL.Path)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -146,7 +146,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
|
|||||||
|
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -162,7 +162,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
|
|||||||
s.ServeHTTPNext(w, r)
|
s.ServeHTTPNext(w, r)
|
||||||
return true
|
return true
|
||||||
case config.RuleDeny:
|
case config.RuleDeny:
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
lg.Info("explicit deny")
|
lg.Info("explicit deny")
|
||||||
if rule == nil {
|
if rule == nil {
|
||||||
lg.Error("rule is nil, cannot calculate checksum")
|
lg.Error("rule is nil, cannot calculate checksum")
|
||||||
@ -181,7 +181,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
|
|||||||
s.RenderBench(w, r)
|
s.RenderBench(w, r)
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
|
slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
|
||||||
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"")
|
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"")
|
||||||
return true
|
return true
|
||||||
@ -233,6 +233,8 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
lg = lg.With("check_result", cr)
|
lg = lg.With("check_result", cr)
|
||||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||||
|
|
||||||
|
s.SetCookie(w, anubis.TestCookieName, challenge, "")
|
||||||
|
|
||||||
err = encoder.Encode(struct {
|
err = encoder.Encode(struct {
|
||||||
Rules *config.ChallengeRules `json:"rules"`
|
Rules *config.ChallengeRules `json:"rules"`
|
||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
@ -265,14 +267,14 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
cr, rule, err := s.check(r)
|
cr, rule, err := s.check(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Error("check failed", "err", err)
|
lg.Error("check failed", "err", err)
|
||||||
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".\"")
|
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lg = lg.With("check_result", cr)
|
lg = lg.With("check_result", cr)
|
||||||
|
|
||||||
nonceStr := r.FormValue("nonce")
|
nonceStr := r.FormValue("nonce")
|
||||||
if nonceStr == "" {
|
if nonceStr == "" {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
lg.Debug("no nonce")
|
lg.Debug("no nonce")
|
||||||
s.respondWithError(w, r, "missing nonce")
|
s.respondWithError(w, r, "missing nonce")
|
||||||
return
|
return
|
||||||
@ -280,7 +282,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
elapsedTimeStr := r.FormValue("elapsedTime")
|
elapsedTimeStr := r.FormValue("elapsedTime")
|
||||||
if elapsedTimeStr == "" {
|
if elapsedTimeStr == "" {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
lg.Debug("no elapsedTime")
|
lg.Debug("no elapsedTime")
|
||||||
s.respondWithError(w, r, "missing elapsedTime")
|
s.respondWithError(w, r, "missing elapsedTime")
|
||||||
return
|
return
|
||||||
@ -288,7 +290,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
|
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
lg.Debug("elapsedTime doesn't parse", "err", err)
|
lg.Debug("elapsedTime doesn't parse", "err", err)
|
||||||
s.respondWithError(w, r, "invalid elapsedTime")
|
s.respondWithError(w, r, "invalid elapsedTime")
|
||||||
return
|
return
|
||||||
@ -310,11 +312,21 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||||
|
|
||||||
|
if _, err := r.Cookie(anubis.TestCookieName); err == http.ErrNoCookie {
|
||||||
|
s.ClearCookie(w, s.cookieName)
|
||||||
|
s.ClearCookie(w, anubis.TestCookieName)
|
||||||
|
lg.Warn("user has cookies disabled, this is not an anubis bug")
|
||||||
|
s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ClearCookie(w, anubis.TestCookieName)
|
||||||
|
|
||||||
nonce, err := strconv.Atoi(nonceStr)
|
nonce, err := strconv.Atoi(nonceStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
lg.Debug("nonce doesn't parse", "err", err)
|
lg.Debug("nonce doesn't parse", "err", err)
|
||||||
s.respondWithError(w, r, "invalid nonce")
|
s.respondWithError(w, r, "invalid response")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,7 +334,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
calculated := internal.SHA256sum(calcString)
|
calculated := internal.SHA256sum(calcString)
|
||||||
|
|
||||||
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
lg.Debug("hash does not match", "got", response, "want", calculated)
|
lg.Debug("hash does not match", "got", response, "want", calculated)
|
||||||
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
||||||
failedValidations.Inc()
|
failedValidations.Inc()
|
||||||
@ -331,7 +343,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// compare the leading zeroes
|
// compare the leading zeroes
|
||||||
if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
|
if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
|
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
|
||||||
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
||||||
failedValidations.Inc()
|
failedValidations.Inc()
|
||||||
@ -355,20 +367,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
tokenString, err := token.SignedString(s.priv)
|
tokenString, err := token.SignedString(s.priv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Error("failed to sign JWT", "err", err)
|
lg.Error("failed to sign JWT", "err", err)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName)
|
||||||
s.respondWithError(w, r, "failed to sign JWT")
|
s.respondWithError(w, r, "failed to sign JWT")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
s.SetCookie(w, s.cookieName, tokenString, cookiePath)
|
||||||
Name: s.cookieName,
|
|
||||||
Value: tokenString,
|
|
||||||
Expires: time.Now().Add(s.opts.CookieExpiration),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
Domain: s.opts.CookieDomain,
|
|
||||||
Partitioned: s.opts.CookiePartitioned,
|
|
||||||
Path: cookiePath,
|
|
||||||
})
|
|
||||||
|
|
||||||
challengesValidated.Inc()
|
challengesValidated.Inc()
|
||||||
lg.Debug("challenge passed, redirecting to app")
|
lg.Debug("challenge passed, redirecting to app")
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@ -43,10 +44,10 @@ type challenge struct {
|
|||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
|
func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challenge {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
resp, err := ts.Client().Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("can't request challenge: %v", err)
|
t.Fatalf("can't request challenge: %v", err)
|
||||||
}
|
}
|
||||||
@ -60,6 +61,54 @@ func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
|
|||||||
return chall
|
return chall
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challenge) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
nonce := 0
|
||||||
|
elapsedTime := 420
|
||||||
|
redir := "/"
|
||||||
|
calculated := ""
|
||||||
|
calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
|
||||||
|
calculated = internal.SHA256sum(calcString)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("can't make request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("response", calculated)
|
||||||
|
q.Set("nonce", fmt.Sprint(nonce))
|
||||||
|
q.Set("redir", redir)
|
||||||
|
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("can't do request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpClient(t *testing.T) *http.Client {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := &http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadPolicies(t *testing.T) {
|
func TestLoadPolicies(t *testing.T) {
|
||||||
for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
|
for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
|
||||||
t.Run(fname, func(t *testing.T) {
|
t.Run(fname, func(t *testing.T) {
|
||||||
@ -85,7 +134,6 @@ func TestCVE2025_24369(t *testing.T) {
|
|||||||
Next: http.NewServeMux(),
|
Next: http.NewServeMux(),
|
||||||
Policy: pol,
|
Policy: pol,
|
||||||
|
|
||||||
CookieDomain: ".local.cetacean.club",
|
|
||||||
CookiePartitioned: true,
|
CookiePartitioned: true,
|
||||||
CookieName: t.Name(),
|
CookieName: t.Name(),
|
||||||
})
|
})
|
||||||
@ -93,34 +141,9 @@ func TestCVE2025_24369(t *testing.T) {
|
|||||||
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
chall := makeChallenge(t, ts)
|
cli := httpClient(t)
|
||||||
calcString := fmt.Sprintf("%s%d", chall.Challenge, 0)
|
chall := makeChallenge(t, ts, cli)
|
||||||
calculated := internal.SHA256sum(calcString)
|
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
|
||||||
nonce := 0
|
|
||||||
elapsedTime := 420
|
|
||||||
redir := "/"
|
|
||||||
|
|
||||||
cli := ts.Client()
|
|
||||||
cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't make request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := req.URL.Query()
|
|
||||||
q.Set("response", calculated)
|
|
||||||
q.Set("nonce", fmt.Sprint(nonce))
|
|
||||||
q.Set("redir", redir)
|
|
||||||
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
|
||||||
req.URL.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
resp, err := cli.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't do challenge passing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusFound {
|
if resp.StatusCode == http.StatusFound {
|
||||||
t.Log("Regression on CVE-2025-24369")
|
t.Log("Regression on CVE-2025-24369")
|
||||||
@ -137,58 +160,18 @@ func TestCookieCustomExpiration(t *testing.T) {
|
|||||||
Next: http.NewServeMux(),
|
Next: http.NewServeMux(),
|
||||||
Policy: pol,
|
Policy: pol,
|
||||||
|
|
||||||
CookieDomain: "local.cetacean.club",
|
|
||||||
CookieName: t.Name(),
|
|
||||||
CookieExpiration: ckieExpiration,
|
CookieExpiration: ckieExpiration,
|
||||||
})
|
})
|
||||||
|
|
||||||
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
cli := &http.Client{
|
cli := httpClient(t)
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
chall := makeChallenge(t, ts, cli)
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
requestReceiveLowerBound := time.Now().Add(-1 * time.Minute)
|
||||||
if err != nil {
|
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
|
||||||
t.Fatalf("can't request challenge: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var chall = struct {
|
|
||||||
Challenge string `json:"challenge"`
|
|
||||||
}{}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
|
|
||||||
t.Fatalf("can't read challenge response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce := 0
|
|
||||||
elapsedTime := 420
|
|
||||||
redir := "/"
|
|
||||||
calculated := ""
|
|
||||||
calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
|
|
||||||
calculated = internal.SHA256sum(calcString)
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't make request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := req.URL.Query()
|
|
||||||
q.Set("response", calculated)
|
|
||||||
q.Set("nonce", fmt.Sprint(nonce))
|
|
||||||
q.Set("redir", redir)
|
|
||||||
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
|
||||||
req.URL.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
requestReceiveLowerBound := time.Now()
|
|
||||||
resp, err = cli.Do(req)
|
|
||||||
requestReceiveUpperBound := time.Now()
|
requestReceiveUpperBound := time.Now()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't do challenge passing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusFound {
|
if resp.StatusCode != http.StatusFound {
|
||||||
resp.Write(os.Stderr)
|
resp.Write(os.Stderr)
|
||||||
@ -226,59 +209,21 @@ func TestCookieSettings(t *testing.T) {
|
|||||||
Next: http.NewServeMux(),
|
Next: http.NewServeMux(),
|
||||||
Policy: pol,
|
Policy: pol,
|
||||||
|
|
||||||
CookieDomain: "local.cetacean.club",
|
CookieDomain: "127.0.0.1",
|
||||||
CookiePartitioned: true,
|
CookiePartitioned: true,
|
||||||
CookieName: t.Name(),
|
CookieName: t.Name(),
|
||||||
CookieExpiration: anubis.CookieDefaultExpirationTime,
|
CookieExpiration: anubis.CookieDefaultExpirationTime,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
requestReceiveLowerBound := time.Now()
|
||||||
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
cli := &http.Client{
|
cli := httpClient(t)
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
chall := makeChallenge(t, ts, cli)
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't request challenge: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var chall = struct {
|
|
||||||
Challenge string `json:"challenge"`
|
|
||||||
}{}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
|
|
||||||
t.Fatalf("can't read challenge response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce := 0
|
|
||||||
elapsedTime := 420
|
|
||||||
redir := "/"
|
|
||||||
calculated := ""
|
|
||||||
calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
|
|
||||||
calculated = internal.SHA256sum(calcString)
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't make request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := req.URL.Query()
|
|
||||||
q.Set("response", calculated)
|
|
||||||
q.Set("nonce", fmt.Sprint(nonce))
|
|
||||||
q.Set("redir", redir)
|
|
||||||
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
|
||||||
req.URL.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
requestReceiveLowerBound := time.Now()
|
|
||||||
resp, err = cli.Do(req)
|
|
||||||
requestReceiveUpperBound := time.Now()
|
requestReceiveUpperBound := time.Now()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't do challenge passing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusFound {
|
if resp.StatusCode != http.StatusFound {
|
||||||
resp.Write(os.Stderr)
|
resp.Write(os.Stderr)
|
||||||
@ -298,8 +243,8 @@ func TestCookieSettings(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ckie.Domain != "local.cetacean.club" {
|
if ckie.Domain != "127.0.0.1" {
|
||||||
t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
|
t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
|
expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
|
||||||
@ -457,6 +402,10 @@ func TestBasePrefix(t *testing.T) {
|
|||||||
t.Fatalf("can't make request: %v", err)
|
t.Fatalf("can't make request: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, ckie := range resp.Cookies() {
|
||||||
|
req.AddCookie(ckie)
|
||||||
|
}
|
||||||
|
|
||||||
q := req.URL.Query()
|
q := req.URL.Query()
|
||||||
q.Set("response", calculated)
|
q.Set("response", calculated)
|
||||||
q.Set("nonce", fmt.Sprint(nonce))
|
q.Set("nonce", fmt.Sprint(nonce))
|
||||||
@ -561,6 +510,25 @@ func TestCloudflareWorkersRule(t *testing.T) {
|
|||||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("with-cf-worker-header", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("X-Real-Ip", "127.0.0.1")
|
||||||
|
req.Header.Add("Cf-Worker", "true")
|
||||||
|
|
||||||
|
cr, _, err := s.check(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cr.Rule != config.RuleDeny {
|
||||||
|
t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleDeny, cr.Rule)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("no-cf-worker-header", func(t *testing.T) {
|
t.Run("no-cf-worker-header", func(t *testing.T) {
|
||||||
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
34
lib/http.go
34
lib/http.go
@ -1,19 +1,34 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis"
|
||||||
"github.com/TecharoHQ/anubis/internal"
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
"github.com/TecharoHQ/anubis/lib/policy"
|
"github.com/TecharoHQ/anubis/lib/policy"
|
||||||
"github.com/TecharoHQ/anubis/web"
|
"github.com/TecharoHQ/anubis/web"
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) ClearCookie(w http.ResponseWriter) {
|
func (s *Server) SetCookie(w http.ResponseWriter, name, value, path string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: s.cookieName,
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Expires: time.Now().Add(s.opts.CookieExpiration),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Domain: s.opts.CookieDomain,
|
||||||
|
Partitioned: s.opts.CookiePartitioned,
|
||||||
|
Path: path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ClearCookie(w http.ResponseWriter, name string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
Value: "",
|
Value: "",
|
||||||
Expires: time.Now().Add(-1 * time.Hour),
|
Expires: time.Now().Add(-1 * time.Hour),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
@ -38,6 +53,10 @@ func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
return t.Transport.RoundTrip(req)
|
return t.Transport.RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func randomChance(n int) bool {
|
||||||
|
return rand.Intn(n) == 0
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) {
|
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) {
|
||||||
if returnHTTPStatusOnly {
|
if returnHTTPStatusOnly {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
@ -47,6 +66,11 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
|
|||||||
|
|
||||||
lg := internal.GetRequestLogger(r)
|
lg := internal.GetRequestLogger(r)
|
||||||
|
|
||||||
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
|
||||||
|
lg.Error("client was given a challenge but does not in fact support gzip compression")
|
||||||
|
s.respondWithError(w, r, "Client Error: Please ensure your browser is up to date and try again later.")
|
||||||
|
}
|
||||||
|
|
||||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||||
|
|
||||||
var ogTags map[string]string = nil
|
var ogTags map[string]string = nil
|
||||||
@ -58,6 +82,8 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.SetCookie(w, anubis.TestCookieName, challenge, "")
|
||||||
|
|
||||||
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags)
|
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Error("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.
|
lg.Error("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.
|
||||||
@ -65,10 +91,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := internal.NoStoreCache(templ.Handler(
|
handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(
|
||||||
component,
|
component,
|
||||||
templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
|
templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
|
||||||
))
|
)))
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ func TestClearCookie(t *testing.T) {
|
|||||||
srv := spawnAnubis(t, Options{})
|
srv := spawnAnubis(t, Options{})
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
srv.ClearCookie(rw)
|
srv.ClearCookie(rw, srv.cookieName)
|
||||||
|
|
||||||
resp := rw.Result()
|
resp := rw.Result()
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ func TestClearCookieWithDomain(t *testing.T) {
|
|||||||
srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"})
|
srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"})
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
srv.ClearCookie(rw)
|
srv.ClearCookie(rw, srv.cookieName)
|
||||||
|
|
||||||
resp := rw.Result()
|
resp := rw.Result()
|
||||||
|
|
||||||
|
6
lib/testdata/cloudflare-workers-cel.yaml
vendored
6
lib/testdata/cloudflare-workers-cel.yaml
vendored
@ -1,4 +1,8 @@
|
|||||||
bots:
|
bots:
|
||||||
- name: cloudflare-workers
|
- name: cloudflare-workers
|
||||||
expression: '"Cf-Worker" in headers'
|
expression: '"Cf-Worker" in headers'
|
||||||
action: DENY
|
action: DENY
|
||||||
|
|
||||||
|
status_codes:
|
||||||
|
CHALLENGE: 401
|
||||||
|
DENY: 403
|
6
lib/testdata/cloudflare-workers-header.yaml
vendored
6
lib/testdata/cloudflare-workers-header.yaml
vendored
@ -2,4 +2,8 @@ bots:
|
|||||||
- name: cloudflare-workers
|
- name: cloudflare-workers
|
||||||
headers_regex:
|
headers_regex:
|
||||||
CF-Worker: .*
|
CF-Worker: .*
|
||||||
action: DENY
|
action: DENY
|
||||||
|
|
||||||
|
status_codes:
|
||||||
|
CHALLENGE: 401
|
||||||
|
DENY: 403
|
@ -10,7 +10,7 @@
|
|||||||
"test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker",
|
"test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker",
|
||||||
"assets": "go generate ./... && ./web/build.sh && ./xess/build.sh",
|
"assets": "go generate ./... && ./web/build.sh && ./xess/build.sh",
|
||||||
"build": "npm run assets && go build -o ./var/anubis ./cmd/anubis",
|
"build": "npm run assets && go build -o ./var/anubis ./cmd/anubis",
|
||||||
"dev": "npm run assets && go run ./cmd/anubis --use-remote-address",
|
"dev": "npm run assets && go run ./cmd/anubis --use-remote-address --target http://localhost:3000",
|
||||||
"container": "npm run assets && go run ./cmd/containerbuild",
|
"container": "npm run assets && go run ./cmd/containerbuild",
|
||||||
"package": "yeet",
|
"package": "yeet",
|
||||||
"lint": "make lint"
|
"lint": "make lint"
|
||||||
@ -27,4 +27,4 @@
|
|||||||
"postcss-import-url": "^7.2.0",
|
"postcss-import-url": "^7.2.0",
|
||||||
"postcss-url": "^10.1.3"
|
"postcss-url": "^10.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user