anubis_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. package lib
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "net/http/httptest"
  7. "os"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/TecharoHQ/anubis"
  12. "github.com/TecharoHQ/anubis/data"
  13. "github.com/TecharoHQ/anubis/internal"
  14. "github.com/TecharoHQ/anubis/lib/policy"
  15. "github.com/TecharoHQ/anubis/lib/policy/config"
  16. )
  17. func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
  18. t.Helper()
  19. anubisPolicy, err := LoadPoliciesOrDefault(fname, anubis.DefaultDifficulty)
  20. if err != nil {
  21. t.Fatal(err)
  22. }
  23. return anubisPolicy
  24. }
  25. func spawnAnubis(t *testing.T, opts Options) *Server {
  26. t.Helper()
  27. s, err := New(opts)
  28. if err != nil {
  29. t.Fatalf("can't construct libanubis.Server: %v", err)
  30. }
  31. return s
  32. }
  33. type challenge struct {
  34. Challenge string `json:"challenge"`
  35. }
  36. func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
  37. t.Helper()
  38. resp, err := ts.Client().Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
  39. if err != nil {
  40. t.Fatalf("can't request challenge: %v", err)
  41. }
  42. defer resp.Body.Close()
  43. var chall challenge
  44. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  45. t.Fatalf("can't read challenge response body: %v", err)
  46. }
  47. return chall
  48. }
  49. func TestLoadPolicies(t *testing.T) {
  50. for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
  51. t.Run(fname, func(t *testing.T) {
  52. fin, err := data.BotPolicies.Open(fname)
  53. if err != nil {
  54. t.Fatal(err)
  55. }
  56. defer fin.Close()
  57. if _, err := policy.ParseConfig(fin, fname, 4); err != nil {
  58. t.Fatal(err)
  59. }
  60. })
  61. }
  62. }
  63. // Regression test for CVE-2025-24369
  64. func TestCVE2025_24369(t *testing.T) {
  65. pol := loadPolicies(t, "")
  66. pol.DefaultDifficulty = 4
  67. srv := spawnAnubis(t, Options{
  68. Next: http.NewServeMux(),
  69. Policy: pol,
  70. CookieDomain: ".local.cetacean.club",
  71. CookiePartitioned: true,
  72. CookieName: t.Name(),
  73. })
  74. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  75. defer ts.Close()
  76. chall := makeChallenge(t, ts)
  77. calcString := fmt.Sprintf("%s%d", chall.Challenge, 0)
  78. calculated := internal.SHA256sum(calcString)
  79. nonce := 0
  80. elapsedTime := 420
  81. redir := "/"
  82. cli := ts.Client()
  83. cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  84. return http.ErrUseLastResponse
  85. }
  86. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  87. if err != nil {
  88. t.Fatalf("can't make request: %v", err)
  89. }
  90. q := req.URL.Query()
  91. q.Set("response", calculated)
  92. q.Set("nonce", fmt.Sprint(nonce))
  93. q.Set("redir", redir)
  94. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  95. req.URL.RawQuery = q.Encode()
  96. resp, err := cli.Do(req)
  97. if err != nil {
  98. t.Fatalf("can't do challenge passing")
  99. }
  100. if resp.StatusCode == http.StatusFound {
  101. t.Log("Regression on CVE-2025-24369")
  102. t.Errorf("wanted HTTP status %d, got: %d", http.StatusForbidden, resp.StatusCode)
  103. }
  104. }
  105. func TestCookieCustomExpiration(t *testing.T) {
  106. pol := loadPolicies(t, "")
  107. pol.DefaultDifficulty = 0
  108. ckieExpiration := 10 * time.Minute
  109. srv := spawnAnubis(t, Options{
  110. Next: http.NewServeMux(),
  111. Policy: pol,
  112. CookieDomain: "local.cetacean.club",
  113. CookieName: t.Name(),
  114. CookieExpiration: ckieExpiration,
  115. })
  116. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  117. defer ts.Close()
  118. cli := &http.Client{
  119. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  120. return http.ErrUseLastResponse
  121. },
  122. }
  123. resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
  124. if err != nil {
  125. t.Fatalf("can't request challenge: %v", err)
  126. }
  127. defer resp.Body.Close()
  128. var chall = struct {
  129. Challenge string `json:"challenge"`
  130. }{}
  131. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  132. t.Fatalf("can't read challenge response body: %v", err)
  133. }
  134. nonce := 0
  135. elapsedTime := 420
  136. redir := "/"
  137. calculated := ""
  138. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  139. calculated = internal.SHA256sum(calcString)
  140. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  141. if err != nil {
  142. t.Fatalf("can't make request: %v", err)
  143. }
  144. q := req.URL.Query()
  145. q.Set("response", calculated)
  146. q.Set("nonce", fmt.Sprint(nonce))
  147. q.Set("redir", redir)
  148. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  149. req.URL.RawQuery = q.Encode()
  150. requestReceiveLowerBound := time.Now()
  151. resp, err = cli.Do(req)
  152. requestReceiveUpperBound := time.Now()
  153. if err != nil {
  154. t.Fatalf("can't do challenge passing")
  155. }
  156. if resp.StatusCode != http.StatusFound {
  157. resp.Write(os.Stderr)
  158. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  159. }
  160. var ckie *http.Cookie
  161. for _, cookie := range resp.Cookies() {
  162. t.Logf("%#v", cookie)
  163. if cookie.Name == anubis.CookieName {
  164. ckie = cookie
  165. break
  166. }
  167. }
  168. if ckie == nil {
  169. t.Errorf("Cookie %q not found", anubis.CookieName)
  170. return
  171. }
  172. expirationLowerBound := requestReceiveLowerBound.Add(ckieExpiration)
  173. expirationUpperBound := requestReceiveUpperBound.Add(ckieExpiration)
  174. // Since the cookie expiration precision is only to the second due to the Unix() call, we can
  175. // lower the level of expected precision.
  176. if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
  177. t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
  178. return
  179. }
  180. }
  181. func TestCookieSettings(t *testing.T) {
  182. pol := loadPolicies(t, "")
  183. pol.DefaultDifficulty = 0
  184. srv := spawnAnubis(t, Options{
  185. Next: http.NewServeMux(),
  186. Policy: pol,
  187. CookieDomain: "local.cetacean.club",
  188. CookiePartitioned: true,
  189. CookieName: t.Name(),
  190. CookieExpiration: anubis.CookieDefaultExpirationTime,
  191. })
  192. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  193. defer ts.Close()
  194. cli := &http.Client{
  195. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  196. return http.ErrUseLastResponse
  197. },
  198. }
  199. resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
  200. if err != nil {
  201. t.Fatalf("can't request challenge: %v", err)
  202. }
  203. defer resp.Body.Close()
  204. var chall = struct {
  205. Challenge string `json:"challenge"`
  206. }{}
  207. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  208. t.Fatalf("can't read challenge response body: %v", err)
  209. }
  210. nonce := 0
  211. elapsedTime := 420
  212. redir := "/"
  213. calculated := ""
  214. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  215. calculated = internal.SHA256sum(calcString)
  216. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  217. if err != nil {
  218. t.Fatalf("can't make request: %v", err)
  219. }
  220. q := req.URL.Query()
  221. q.Set("response", calculated)
  222. q.Set("nonce", fmt.Sprint(nonce))
  223. q.Set("redir", redir)
  224. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  225. req.URL.RawQuery = q.Encode()
  226. requestReceiveLowerBound := time.Now()
  227. resp, err = cli.Do(req)
  228. requestReceiveUpperBound := time.Now()
  229. if err != nil {
  230. t.Fatalf("can't do challenge passing")
  231. }
  232. if resp.StatusCode != http.StatusFound {
  233. resp.Write(os.Stderr)
  234. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  235. }
  236. var ckie *http.Cookie
  237. for _, cookie := range resp.Cookies() {
  238. t.Logf("%#v", cookie)
  239. if cookie.Name == anubis.CookieName {
  240. ckie = cookie
  241. break
  242. }
  243. }
  244. if ckie == nil {
  245. t.Errorf("Cookie %q not found", anubis.CookieName)
  246. return
  247. }
  248. if ckie.Domain != "local.cetacean.club" {
  249. t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
  250. }
  251. expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
  252. expirationUpperBound := requestReceiveUpperBound.Add(anubis.CookieDefaultExpirationTime)
  253. // Since the cookie expiration precision is only to the second due to the Unix() call, we can
  254. // lower the level of expected precision.
  255. if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
  256. t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
  257. return
  258. }
  259. if ckie.Partitioned != srv.opts.CookiePartitioned {
  260. t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
  261. }
  262. }
  263. func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
  264. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  265. fmt.Fprintln(w, "OK")
  266. })
  267. for i := 1; i < 10; i++ {
  268. t.Run(fmt.Sprint(i), func(t *testing.T) {
  269. anubisPolicy, err := LoadPoliciesOrDefault("", i)
  270. if err != nil {
  271. t.Fatal(err)
  272. }
  273. s, err := New(Options{
  274. Next: h,
  275. Policy: anubisPolicy,
  276. ServeRobotsTXT: true,
  277. })
  278. if err != nil {
  279. t.Fatalf("can't construct libanubis.Server: %v", err)
  280. }
  281. req, err := http.NewRequest(http.MethodGet, "/", nil)
  282. if err != nil {
  283. t.Fatal(err)
  284. }
  285. req.Header.Add("X-Real-Ip", "127.0.0.1")
  286. _, bot, err := s.check(req)
  287. if err != nil {
  288. t.Fatal(err)
  289. }
  290. if bot.Challenge.Difficulty != i {
  291. t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
  292. }
  293. if bot.Challenge.ReportAs != i {
  294. t.Errorf("Challenge.ReportAs is wrong, wanted %d, got: %d", i, bot.Challenge.ReportAs)
  295. }
  296. })
  297. }
  298. }
  299. func TestBasePrefix(t *testing.T) {
  300. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  301. fmt.Fprintln(w, "OK")
  302. })
  303. testCases := []struct {
  304. name string
  305. basePrefix string
  306. path string
  307. expected string
  308. }{
  309. {
  310. name: "no prefix",
  311. basePrefix: "",
  312. path: "/.within.website/x/cmd/anubis/api/make-challenge",
  313. expected: "/.within.website/x/cmd/anubis/api/make-challenge",
  314. },
  315. {
  316. name: "with prefix",
  317. basePrefix: "/myapp",
  318. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  319. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  320. },
  321. {
  322. name: "with prefix and trailing slash",
  323. basePrefix: "/myapp/",
  324. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  325. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  326. },
  327. }
  328. for _, tc := range testCases {
  329. t.Run(tc.name, func(t *testing.T) {
  330. // Reset the global BasePrefix before each test
  331. anubis.BasePrefix = ""
  332. pol := loadPolicies(t, "")
  333. pol.DefaultDifficulty = 4
  334. srv := spawnAnubis(t, Options{
  335. Next: h,
  336. Policy: pol,
  337. BasePrefix: tc.basePrefix,
  338. })
  339. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  340. defer ts.Close()
  341. // Test API endpoint with prefix
  342. resp, err := ts.Client().Post(ts.URL+tc.path, "", nil)
  343. if err != nil {
  344. t.Fatalf("can't request challenge: %v", err)
  345. }
  346. defer resp.Body.Close()
  347. if resp.StatusCode != http.StatusOK {
  348. t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode)
  349. }
  350. var chall challenge
  351. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  352. t.Fatalf("can't read challenge response body: %v", err)
  353. }
  354. if chall.Challenge == "" {
  355. t.Errorf("expected non-empty challenge")
  356. }
  357. // Test cookie path when passing challenge
  358. // Find a nonce that produces a hash with the required number of leading zeros
  359. nonce := 0
  360. var calculated string
  361. for {
  362. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  363. calculated = internal.SHA256sum(calcString)
  364. if strings.HasPrefix(calculated, strings.Repeat("0", pol.DefaultDifficulty)) {
  365. break
  366. }
  367. nonce++
  368. }
  369. elapsedTime := 420
  370. redir := "/"
  371. cli := ts.Client()
  372. cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  373. return http.ErrUseLastResponse
  374. }
  375. // Construct the correct path for pass-challenge
  376. passChallengePath := tc.path
  377. passChallengePath = passChallengePath[:strings.LastIndex(passChallengePath, "/")+1] + "pass-challenge"
  378. req, err := http.NewRequest(http.MethodGet, ts.URL+passChallengePath, nil)
  379. if err != nil {
  380. t.Fatalf("can't make request: %v", err)
  381. }
  382. q := req.URL.Query()
  383. q.Set("response", calculated)
  384. q.Set("nonce", fmt.Sprint(nonce))
  385. q.Set("redir", redir)
  386. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  387. req.URL.RawQuery = q.Encode()
  388. resp, err = cli.Do(req)
  389. if err != nil {
  390. t.Fatalf("can't do challenge passing: %v", err)
  391. }
  392. if resp.StatusCode != http.StatusFound {
  393. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  394. }
  395. // Check cookie path
  396. var ckie *http.Cookie
  397. for _, cookie := range resp.Cookies() {
  398. if cookie.Name == anubis.CookieName {
  399. ckie = cookie
  400. break
  401. }
  402. }
  403. if ckie == nil {
  404. t.Errorf("Cookie %q not found", anubis.CookieName)
  405. return
  406. }
  407. expectedPath := "/"
  408. if tc.basePrefix != "" {
  409. expectedPath = strings.TrimSuffix(tc.basePrefix, "/") + "/"
  410. }
  411. if ckie.Path != expectedPath {
  412. t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path)
  413. }
  414. })
  415. }
  416. }
  417. func TestCustomStatusCodes(t *testing.T) {
  418. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  419. t.Log(r.UserAgent())
  420. w.WriteHeader(http.StatusOK)
  421. fmt.Fprintln(w, "OK")
  422. })
  423. statusMap := map[string]int{
  424. "ALLOW": 200,
  425. "CHALLENGE": 401,
  426. "DENY": 403,
  427. }
  428. pol := loadPolicies(t, "./testdata/aggressive_403.yaml")
  429. pol.DefaultDifficulty = 4
  430. srv := spawnAnubis(t, Options{
  431. Next: h,
  432. Policy: pol,
  433. })
  434. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  435. defer ts.Close()
  436. for userAgent, statusCode := range statusMap {
  437. t.Run(userAgent, func(t *testing.T) {
  438. req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, ts.URL, nil)
  439. if err != nil {
  440. t.Fatal(err)
  441. }
  442. req.Header.Set("User-Agent", userAgent)
  443. resp, err := ts.Client().Do(req)
  444. if err != nil {
  445. t.Fatal(err)
  446. }
  447. if resp.StatusCode != statusCode {
  448. t.Errorf("wanted status code %d but got: %d", statusCode, resp.StatusCode)
  449. }
  450. })
  451. }
  452. }
  453. func TestCloudflareWorkersRule(t *testing.T) {
  454. for _, variant := range []string{"cel", "header"} {
  455. t.Run(variant, func(t *testing.T) {
  456. pol := loadPolicies(t, "./testdata/cloudflare-workers-"+variant+".yaml")
  457. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  458. fmt.Fprintln(w, "OK")
  459. })
  460. s, err := New(Options{
  461. Next: h,
  462. Policy: pol,
  463. ServeRobotsTXT: true,
  464. })
  465. if err != nil {
  466. t.Fatalf("can't construct libanubis.Server: %v", err)
  467. }
  468. t.Run("no-cf-worker-header", func(t *testing.T) {
  469. req, err := http.NewRequest(http.MethodGet, "/", nil)
  470. if err != nil {
  471. t.Fatal(err)
  472. }
  473. req.Header.Add("X-Real-Ip", "127.0.0.1")
  474. cr, _, err := s.check(req)
  475. if err != nil {
  476. t.Fatal(err)
  477. }
  478. if cr.Rule != config.RuleAllow {
  479. t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleAllow, cr.Rule)
  480. }
  481. })
  482. })
  483. }
  484. }