Weighs the soul of incoming HTTP requests to stop AI crawlers

fix: improve error handling and create the json encoder once #331 (#332)

* fix: improve error handling for resource closing and JSON encoding in MakeChallenge

* chore: update CHANGELOG with recent changes and improvements

* refactor: simplify RenderIndex function and improve error handling

---------

Signed-off-by: Jason Cameron <git@jasoncameron.dev>

authored by Jason Cameron and committed by GitHub 78bb67fb 2db41054

Changed files
+48 -23
cmd
anubis
containerbuild
docs
internal
lib
+6 -3
cmd/anubis/main.go
··· 117 117 118 118 err = os.Chmod(address, os.FileMode(mode)) 119 119 if err != nil { 120 - listener.Close() 120 + err := listener.Close() 121 + if err != nil { 122 + log.Printf("failed to close listener: %v", err) 123 + } 121 124 log.Fatal(fmt.Errorf("could not change socket mode: %w", err)) 122 125 } 123 126 } ··· 227 230 log.Fatalf("failed to parse and validate ED25519_PRIVATE_KEY_HEX: %v", err) 228 231 } 229 232 } else if *ed25519PrivateKeyHexFile != "" { 230 - hex, err := os.ReadFile(*ed25519PrivateKeyHexFile) 233 + hexData, err := os.ReadFile(*ed25519PrivateKeyHexFile) 231 234 if err != nil { 232 235 log.Fatalf("failed to read ED25519_PRIVATE_KEY_HEX_FILE %s: %v", *ed25519PrivateKeyHexFile, err) 233 236 } 234 237 235 - priv, err = keyFromHex(string(bytes.TrimSpace(hex))) 238 + priv, err = keyFromHex(string(bytes.TrimSpace(hexData))) 236 239 if err != nil { 237 240 log.Fatalf("failed to parse and validate content of ED25519_PRIVATE_KEY_HEX_FILE: %v", err) 238 241 }
+1 -1
cmd/containerbuild/main.go
··· 131 131 } 132 132 133 133 if len(result) == 0 { 134 - return nil, fmt.Errorf("no images provided, bad flags??") 134 + return nil, fmt.Errorf("no images provided, bad flags") 135 135 } 136 136 137 137 return result, nil
+1
docs/docs/CHANGELOG.md
··· 26 26 - Added headers support to bot policy rules 27 27 - Moved configuration file from JSON to YAML by default 28 28 - Added documentation on how to use Anubis with Traefik in Docker 29 + - Improved error handling in some edge cases 29 30 - Disable `generic-bot-catchall` rule because of its high false positive rate in real-world scenarios 30 31 31 32 ## v1.16.0
+1 -1
internal/headers.go
··· 73 73 }) 74 74 } 75 75 76 - // Do not allow browsing directory listings in paths that end with / 76 + // NoBrowsing prevents directory browsing by returning a 404 for any request that ends with a "/". 77 77 func NoBrowsing(next http.Handler) http.Handler { 78 78 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 79 if strings.HasSuffix(r.URL.Path, "/") {
+7 -1
internal/ogtags/fetch.go
··· 4 4 "errors" 5 5 "fmt" 6 6 "golang.org/x/net/html" 7 + "io" 7 8 "log/slog" 8 9 "mime" 9 10 "net" ··· 26 27 return nil, fmt.Errorf("http get failed: %w", err) 27 28 } 28 29 // this defer will call MaxBytesReader's Close, which closes the original body. 29 - defer resp.Body.Close() 30 + defer func(Body io.ReadCloser) { 31 + err := Body.Close() 32 + if err != nil { 33 + slog.Debug("og: error closing response body", "url", urlStr, "error", err) 34 + } 35 + }(resp.Body) 30 36 31 37 if resp.StatusCode != http.StatusOK { 32 38 slog.Debug("og: received non-OK status code", "url", urlStr, "status", resp.StatusCode)
+4 -4
internal/test/playwright_test.go
··· 378 378 } 379 379 380 380 func pwTimeout(tc testCase, deadline time.Time) *float64 { 381 - max := *playwrightMaxTime 381 + maxTime := *playwrightMaxTime 382 382 if tc.isHard { 383 - max = *playwrightMaxHardTime 383 + maxTime = *playwrightMaxHardTime 384 384 } 385 385 386 386 d := time.Until(deadline) 387 - if d <= 0 || d > max { 388 - return playwright.Float(float64(max.Milliseconds())) 387 + if d <= 0 || d > maxTime { 388 + return playwright.Float(float64(maxTime.Milliseconds())) 389 389 } 390 390 return playwright.Float(float64(d.Milliseconds())) 391 391 }
+27 -12
lib/anubis.go
··· 96 96 } 97 97 } 98 98 99 - defer fin.Close() 99 + defer func(fin io.ReadCloser) { 100 + err := fin.Close() 101 + if err != nil { 102 + slog.Error("failed to close policy file", "file", fname, "err", err) 103 + } 104 + }(fin) 100 105 101 106 anubisPolicy, err := policy.ParseConfig(fin, fname, defaultDifficulty) 102 107 ··· 201 206 r.Header.Add("X-Anubis-Rule", cr.Name) 202 207 r.Header.Add("X-Anubis-Action", string(cr.Rule)) 203 208 lg = lg.With("check_result", cr) 204 - policy.PolicyApplications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1) 209 + policy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1) 205 210 206 211 ip := r.Header.Get("X-Real-Ip") 207 212 ··· 258 263 if err != nil { 259 264 lg.Debug("cookie not found", "path", r.URL.Path) 260 265 s.ClearCookie(w) 261 - s.RenderIndex(w, r, cr, rule) 266 + s.RenderIndex(w, r, rule) 262 267 return 263 268 } 264 269 265 270 if err := ckie.Valid(); err != nil { 266 271 lg.Debug("cookie is invalid", "err", err) 267 272 s.ClearCookie(w) 268 - s.RenderIndex(w, r, cr, rule) 273 + s.RenderIndex(w, r, rule) 269 274 return 270 275 } 271 276 272 277 if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() { 273 278 lg.Debug("cookie expired", "path", r.URL.Path) 274 279 s.ClearCookie(w) 275 - s.RenderIndex(w, r, cr, rule) 280 + s.RenderIndex(w, r, rule) 276 281 return 277 282 } 278 283 ··· 283 288 if err != nil || !token.Valid { 284 289 lg.Debug("invalid token", "path", r.URL.Path, "err", err) 285 290 s.ClearCookie(w) 286 - s.RenderIndex(w, r, cr, rule) 291 + s.RenderIndex(w, r, rule) 287 292 return 288 293 } 289 294 ··· 298 303 if !ok { 299 304 lg.Debug("invalid token claims type", "path", r.URL.Path) 300 305 s.ClearCookie(w) 301 - s.RenderIndex(w, r, cr, rule) 306 + s.RenderIndex(w, r, rule) 302 307 return 303 308 } 304 309 challenge := s.challengeFor(r, rule.Challenge.Difficulty) ··· 306 311 if claims["challenge"] != challenge { 307 312 lg.Debug("invalid challenge", "path", r.URL.Path) 308 313 s.ClearCookie(w) 309 - s.RenderIndex(w, r, cr, rule) 314 + s.RenderIndex(w, r, rule) 310 315 return 311 316 } 312 317 ··· 323 328 lg.Debug("invalid response", "path", r.URL.Path) 324 329 failedValidations.Inc() 325 330 s.ClearCookie(w) 326 - s.RenderIndex(w, r, cr, rule) 331 + s.RenderIndex(w, r, rule) 327 332 return 328 333 } 329 334 ··· 332 337 s.next.ServeHTTP(w, r) 333 338 } 334 339 335 - func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot) { 340 + func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot) { 336 341 lg := slog.With( 337 342 "user_agent", r.UserAgent(), 338 343 "accept_language", r.Header.Get("Accept-Language"), ··· 374 379 func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { 375 380 lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip")) 376 381 382 + encoder := json.NewEncoder(w) 377 383 cr, rule, err := s.check(r) 378 384 if err != nil { 379 385 lg.Error("check failed", "err", err) 380 386 w.WriteHeader(http.StatusInternalServerError) 381 - json.NewEncoder(w).Encode(struct { 387 + err := encoder.Encode(struct { 382 388 Error string `json:"error"` 383 389 }{ 384 390 Error: "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"makeChallenge\"", 385 391 }) 392 + if err != nil { 393 + lg.Error("failed to encode error response", "err", err) 394 + w.WriteHeader(http.StatusInternalServerError) 395 + } 386 396 return 387 397 } 388 398 lg = lg.With("check_result", cr) 389 399 challenge := s.challengeFor(r, rule.Challenge.Difficulty) 390 400 391 - json.NewEncoder(w).Encode(struct { 401 + err = encoder.Encode(struct { 392 402 Challenge string `json:"challenge"` 393 403 Rules *config.ChallengeRules `json:"rules"` 394 404 }{ 395 405 Challenge: challenge, 396 406 Rules: rule.Challenge, 397 407 }) 408 + if err != nil { 409 + lg.Error("failed to encode challenge", "err", err) 410 + w.WriteHeader(http.StatusInternalServerError) 411 + return 412 + } 398 413 lg.Debug("made challenge", "challenge", challenge, "rules", rule.Challenge, "cr", cr) 399 414 challengesIssued.Inc() 400 415 }
+1 -1
lib/policy/policy.go
··· 13 13 ) 14 14 15 15 var ( 16 - PolicyApplications = promauto.NewCounterVec(prometheus.CounterOpts{ 16 + Applications = promauto.NewCounterVec(prometheus.CounterOpts{ 17 17 Name: "anubis_policy_results", 18 18 Help: "The results of each policy rule", 19 19 }, []string{"rule", "action"})