anubis_test.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. package lib
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "net/http/httptest"
  7. "os"
  8. "strings"
  9. "testing"
  10. "github.com/TecharoHQ/anubis"
  11. "github.com/TecharoHQ/anubis/data"
  12. "github.com/TecharoHQ/anubis/internal"
  13. "github.com/TecharoHQ/anubis/lib/policy"
  14. )
  15. func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
  16. t.Helper()
  17. anubisPolicy, err := LoadPoliciesOrDefault(fname, anubis.DefaultDifficulty)
  18. if err != nil {
  19. t.Fatal(err)
  20. }
  21. return anubisPolicy
  22. }
  23. func spawnAnubis(t *testing.T, opts Options) *Server {
  24. t.Helper()
  25. s, err := New(opts)
  26. if err != nil {
  27. t.Fatalf("can't construct libanubis.Server: %v", err)
  28. }
  29. return s
  30. }
  31. type challenge struct {
  32. Challenge string `json:"challenge"`
  33. }
  34. func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
  35. t.Helper()
  36. resp, err := ts.Client().Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
  37. if err != nil {
  38. t.Fatalf("can't request challenge: %v", err)
  39. }
  40. defer resp.Body.Close()
  41. var chall challenge
  42. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  43. t.Fatalf("can't read challenge response body: %v", err)
  44. }
  45. return chall
  46. }
  47. func TestLoadPolicies(t *testing.T) {
  48. for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
  49. t.Run(fname, func(t *testing.T) {
  50. fin, err := data.BotPolicies.Open(fname)
  51. if err != nil {
  52. t.Fatal(err)
  53. }
  54. defer fin.Close()
  55. if _, err := policy.ParseConfig(fin, fname, 4); err != nil {
  56. t.Fatal(err)
  57. }
  58. })
  59. }
  60. }
  61. // Regression test for CVE-2025-24369
  62. func TestCVE2025_24369(t *testing.T) {
  63. pol := loadPolicies(t, "")
  64. pol.DefaultDifficulty = 4
  65. srv := spawnAnubis(t, Options{
  66. Next: http.NewServeMux(),
  67. Policy: pol,
  68. CookieDomain: "local.cetacean.club",
  69. CookiePartitioned: true,
  70. CookieName: t.Name(),
  71. })
  72. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  73. defer ts.Close()
  74. chall := makeChallenge(t, ts)
  75. calcString := fmt.Sprintf("%s%d", chall.Challenge, 0)
  76. calculated := internal.SHA256sum(calcString)
  77. nonce := 0
  78. elapsedTime := 420
  79. redir := "/"
  80. cli := ts.Client()
  81. cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  82. return http.ErrUseLastResponse
  83. }
  84. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  85. if err != nil {
  86. t.Fatalf("can't make request: %v", err)
  87. }
  88. q := req.URL.Query()
  89. q.Set("response", calculated)
  90. q.Set("nonce", fmt.Sprint(nonce))
  91. q.Set("redir", redir)
  92. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  93. req.URL.RawQuery = q.Encode()
  94. resp, err := cli.Do(req)
  95. if err != nil {
  96. t.Fatalf("can't do challenge passing")
  97. }
  98. if resp.StatusCode == http.StatusFound {
  99. t.Log("Regression on CVE-2025-24369")
  100. t.Errorf("wanted HTTP status %d, got: %d", http.StatusForbidden, resp.StatusCode)
  101. }
  102. }
  103. func TestCookieSettings(t *testing.T) {
  104. pol := loadPolicies(t, "")
  105. pol.DefaultDifficulty = 0
  106. srv := spawnAnubis(t, Options{
  107. Next: http.NewServeMux(),
  108. Policy: pol,
  109. CookieDomain: "local.cetacean.club",
  110. CookiePartitioned: true,
  111. CookieName: t.Name(),
  112. })
  113. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  114. defer ts.Close()
  115. cli := &http.Client{
  116. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  117. return http.ErrUseLastResponse
  118. },
  119. }
  120. resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
  121. if err != nil {
  122. t.Fatalf("can't request challenge: %v", err)
  123. }
  124. defer resp.Body.Close()
  125. var chall = struct {
  126. Challenge string `json:"challenge"`
  127. }{}
  128. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  129. t.Fatalf("can't read challenge response body: %v", err)
  130. }
  131. nonce := 0
  132. elapsedTime := 420
  133. redir := "/"
  134. calculated := ""
  135. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  136. calculated = internal.SHA256sum(calcString)
  137. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  138. if err != nil {
  139. t.Fatalf("can't make request: %v", err)
  140. }
  141. q := req.URL.Query()
  142. q.Set("response", calculated)
  143. q.Set("nonce", fmt.Sprint(nonce))
  144. q.Set("redir", redir)
  145. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  146. req.URL.RawQuery = q.Encode()
  147. resp, err = cli.Do(req)
  148. if err != nil {
  149. t.Fatalf("can't do challenge passing")
  150. }
  151. if resp.StatusCode != http.StatusFound {
  152. resp.Write(os.Stderr)
  153. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  154. }
  155. var ckie *http.Cookie
  156. for _, cookie := range resp.Cookies() {
  157. t.Logf("%#v", cookie)
  158. if cookie.Name == anubis.CookieName {
  159. ckie = cookie
  160. break
  161. }
  162. }
  163. if ckie == nil {
  164. t.Errorf("Cookie %q not found", anubis.CookieName)
  165. return
  166. }
  167. if ckie.Domain != "local.cetacean.club" {
  168. t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
  169. }
  170. if ckie.Partitioned != srv.opts.CookiePartitioned {
  171. t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
  172. }
  173. }
  174. func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
  175. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  176. fmt.Fprintln(w, "OK")
  177. })
  178. for i := 1; i < 10; i++ {
  179. t.Run(fmt.Sprint(i), func(t *testing.T) {
  180. anubisPolicy, err := LoadPoliciesOrDefault("", i)
  181. if err != nil {
  182. t.Fatal(err)
  183. }
  184. s, err := New(Options{
  185. Next: h,
  186. Policy: anubisPolicy,
  187. ServeRobotsTXT: true,
  188. })
  189. if err != nil {
  190. t.Fatalf("can't construct libanubis.Server: %v", err)
  191. }
  192. req, err := http.NewRequest(http.MethodGet, "/", nil)
  193. if err != nil {
  194. t.Fatal(err)
  195. }
  196. req.Header.Add("X-Real-Ip", "127.0.0.1")
  197. _, bot, err := s.check(req)
  198. if err != nil {
  199. t.Fatal(err)
  200. }
  201. if bot.Challenge.Difficulty != i {
  202. t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
  203. }
  204. if bot.Challenge.ReportAs != i {
  205. t.Errorf("Challenge.ReportAs is wrong, wanted %d, got: %d", i, bot.Challenge.ReportAs)
  206. }
  207. })
  208. }
  209. }
  210. func TestBasePrefix(t *testing.T) {
  211. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  212. fmt.Fprintln(w, "OK")
  213. })
  214. testCases := []struct {
  215. name string
  216. basePrefix string
  217. path string
  218. expected string
  219. }{
  220. {
  221. name: "no prefix",
  222. basePrefix: "",
  223. path: "/.within.website/x/cmd/anubis/api/make-challenge",
  224. expected: "/.within.website/x/cmd/anubis/api/make-challenge",
  225. },
  226. {
  227. name: "with prefix",
  228. basePrefix: "/myapp",
  229. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  230. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  231. },
  232. {
  233. name: "with prefix and trailing slash",
  234. basePrefix: "/myapp/",
  235. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  236. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  237. },
  238. }
  239. for _, tc := range testCases {
  240. t.Run(tc.name, func(t *testing.T) {
  241. // Reset the global BasePrefix before each test
  242. anubis.BasePrefix = ""
  243. pol := loadPolicies(t, "")
  244. pol.DefaultDifficulty = 4
  245. srv := spawnAnubis(t, Options{
  246. Next: h,
  247. Policy: pol,
  248. BasePrefix: tc.basePrefix,
  249. })
  250. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  251. defer ts.Close()
  252. // Test API endpoint with prefix
  253. resp, err := ts.Client().Post(ts.URL+tc.path, "", nil)
  254. if err != nil {
  255. t.Fatalf("can't request challenge: %v", err)
  256. }
  257. defer resp.Body.Close()
  258. if resp.StatusCode != http.StatusOK {
  259. t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode)
  260. }
  261. var chall challenge
  262. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  263. t.Fatalf("can't read challenge response body: %v", err)
  264. }
  265. if chall.Challenge == "" {
  266. t.Errorf("expected non-empty challenge")
  267. }
  268. // Test cookie path when passing challenge
  269. // Find a nonce that produces a hash with the required number of leading zeros
  270. nonce := 0
  271. var calculated string
  272. for {
  273. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  274. calculated = internal.SHA256sum(calcString)
  275. if strings.HasPrefix(calculated, strings.Repeat("0", pol.DefaultDifficulty)) {
  276. break
  277. }
  278. nonce++
  279. }
  280. elapsedTime := 420
  281. redir := "/"
  282. cli := ts.Client()
  283. cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  284. return http.ErrUseLastResponse
  285. }
  286. // Construct the correct path for pass-challenge
  287. passChallengePath := tc.path
  288. passChallengePath = passChallengePath[:strings.LastIndex(passChallengePath, "/")+1] + "pass-challenge"
  289. req, err := http.NewRequest(http.MethodGet, ts.URL+passChallengePath, nil)
  290. if err != nil {
  291. t.Fatalf("can't make request: %v", err)
  292. }
  293. q := req.URL.Query()
  294. q.Set("response", calculated)
  295. q.Set("nonce", fmt.Sprint(nonce))
  296. q.Set("redir", redir)
  297. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  298. req.URL.RawQuery = q.Encode()
  299. resp, err = cli.Do(req)
  300. if err != nil {
  301. t.Fatalf("can't do challenge passing: %v", err)
  302. }
  303. if resp.StatusCode != http.StatusFound {
  304. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  305. }
  306. // Check cookie path
  307. var ckie *http.Cookie
  308. for _, cookie := range resp.Cookies() {
  309. if cookie.Name == anubis.CookieName {
  310. ckie = cookie
  311. break
  312. }
  313. }
  314. if ckie == nil {
  315. t.Errorf("Cookie %q not found", anubis.CookieName)
  316. return
  317. }
  318. expectedPath := "/"
  319. if tc.basePrefix != "" {
  320. expectedPath = strings.TrimSuffix(tc.basePrefix, "/") + "/"
  321. }
  322. if ckie.Path != expectedPath {
  323. t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path)
  324. }
  325. })
  326. }
  327. }