anubis_test.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143
  1. package lib
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "net/http"
  10. "net/http/httptest"
  11. "net/url"
  12. "os"
  13. "strings"
  14. "sync"
  15. "testing"
  16. "time"
  17. "github.com/TecharoHQ/anubis"
  18. "github.com/TecharoHQ/anubis/data"
  19. "github.com/TecharoHQ/anubis/internal"
  20. "github.com/TecharoHQ/anubis/lib/challenge"
  21. "github.com/TecharoHQ/anubis/lib/config"
  22. "github.com/TecharoHQ/anubis/lib/policy"
  23. "github.com/TecharoHQ/anubis/lib/store"
  24. "github.com/TecharoHQ/anubis/lib/thoth/thothmock"
  25. )
  26. // TLogWriter implements io.Writer by logging each line to t.Log.
  27. type TLogWriter struct {
  28. t *testing.T
  29. }
  30. // NewTLogWriter returns an io.Writer that sends output to t.Log.
  31. func NewTLogWriter(t *testing.T) io.Writer {
  32. return &TLogWriter{t: t}
  33. }
  34. // Write splits input on newlines and logs each line separately.
  35. func (w *TLogWriter) Write(p []byte) (n int, err error) {
  36. lines := strings.Split(string(p), "\n")
  37. for _, line := range lines {
  38. if line != "" {
  39. w.t.Log(line)
  40. }
  41. }
  42. return len(p), nil
  43. }
  44. func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConfig {
  45. t.Helper()
  46. ctx := thothmock.WithMockThoth(t)
  47. if fname == "" {
  48. fname = "./testdata/test_config.yaml"
  49. }
  50. t.Logf("loading policy file: %s", fname)
  51. anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info")
  52. if err != nil {
  53. t.Fatal(err)
  54. }
  55. return anubisPolicy
  56. }
  57. func spawnAnubis(t *testing.T, opts Options) *Server {
  58. t.Helper()
  59. if opts.Policy == nil {
  60. opts.Policy = loadPolicies(t, "", 4)
  61. }
  62. s, err := New(opts)
  63. if err != nil {
  64. t.Fatalf("can't construct libanubis.Server: %v", err)
  65. }
  66. s.logger = slog.New(slog.NewJSONHandler(&TLogWriter{t: t}, &slog.HandlerOptions{
  67. AddSource: true,
  68. Level: slog.LevelDebug,
  69. }))
  70. return s
  71. }
  72. type challengeResp struct {
  73. ID string `json:"id"`
  74. Challenge string `json:"challenge"`
  75. }
  76. func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challengeResp {
  77. t.Helper()
  78. req, err := http.NewRequest(http.MethodPost, ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", nil)
  79. if err != nil {
  80. t.Fatalf("can't make request: %v", err)
  81. }
  82. q := req.URL.Query()
  83. q.Set("redir", "/")
  84. req.URL.RawQuery = q.Encode()
  85. resp, err := cli.Do(req)
  86. if err != nil {
  87. t.Fatalf("can't request challenge: %v", err)
  88. }
  89. defer resp.Body.Close()
  90. var chall challengeResp
  91. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  92. t.Fatalf("can't read challenge response body: %v", err)
  93. }
  94. return chall
  95. }
  96. func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {
  97. t.Helper()
  98. t.Logf("%#v", chall)
  99. nonce := 0
  100. elapsedTime := 420
  101. redir := "/"
  102. calculated := ""
  103. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  104. calculated = internal.SHA256sum(calcString)
  105. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  106. if err != nil {
  107. t.Fatalf("can't make request: %v", err)
  108. }
  109. q := req.URL.Query()
  110. q.Set("response", calculated)
  111. q.Set("nonce", fmt.Sprint(nonce))
  112. q.Set("redir", redir)
  113. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  114. q.Set("id", chall.ID)
  115. req.URL.RawQuery = q.Encode()
  116. t.Log(q.Encode())
  117. resp, err := cli.Do(req)
  118. if err != nil {
  119. t.Fatalf("can't do request: %v", err)
  120. }
  121. return resp
  122. }
  123. func handleChallengeInvalidProof(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {
  124. t.Helper()
  125. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  126. if err != nil {
  127. t.Fatalf("can't make request: %v", err)
  128. }
  129. q := req.URL.Query()
  130. q.Set("response", strings.Repeat("f", 64)) // "hash" that never starts with the nonce
  131. q.Set("nonce", "0")
  132. q.Set("redir", "/")
  133. q.Set("elapsedTime", "0")
  134. q.Set("id", chall.ID)
  135. req.URL.RawQuery = q.Encode()
  136. resp, err := cli.Do(req)
  137. if err != nil {
  138. t.Fatalf("can't do request: %v", err)
  139. }
  140. return resp
  141. }
  142. type loggingCookieJar struct {
  143. t *testing.T
  144. cookies map[string][]*http.Cookie
  145. lock sync.Mutex
  146. }
  147. func (lcj *loggingCookieJar) Cookies(u *url.URL) []*http.Cookie {
  148. lcj.lock.Lock()
  149. defer lcj.lock.Unlock()
  150. // XXX(Xe): This is not RFC compliant in the slightest.
  151. result, ok := lcj.cookies[u.Host]
  152. if !ok {
  153. return nil
  154. }
  155. lcj.t.Logf("requested cookies for %s", u)
  156. for _, ckie := range result {
  157. lcj.t.Logf("get cookie: <- %s", ckie)
  158. }
  159. return result
  160. }
  161. func (lcj *loggingCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
  162. lcj.lock.Lock()
  163. defer lcj.lock.Unlock()
  164. for _, ckie := range cookies {
  165. lcj.t.Logf("set cookie: %s -> %s", u, ckie)
  166. }
  167. // XXX(Xe): This is not RFC compliant in the slightest.
  168. lcj.cookies[u.Host] = append(lcj.cookies[u.Host], cookies...)
  169. }
  170. type userAgentRoundTripper struct {
  171. rt http.RoundTripper
  172. }
  173. func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
  174. // Only set if not already present
  175. req = req.Clone(req.Context()) // avoid mutating original request
  176. req.Header.Set("User-Agent", "Mozilla/5.0")
  177. req.Header.Set("Accept-Encoding", "gzip")
  178. return u.rt.RoundTrip(req)
  179. }
  180. func httpClient(t *testing.T) *http.Client {
  181. t.Helper()
  182. cli := &http.Client{
  183. Jar: &loggingCookieJar{t: t, cookies: map[string][]*http.Cookie{}},
  184. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  185. return http.ErrUseLastResponse
  186. },
  187. Transport: &userAgentRoundTripper{
  188. rt: http.DefaultTransport,
  189. },
  190. }
  191. return cli
  192. }
  193. func TestLoadPolicies(t *testing.T) {
  194. for _, fname := range []string{"botPolicies.yaml"} {
  195. t.Run(fname, func(t *testing.T) {
  196. fin, err := data.BotPolicies.Open(fname)
  197. if err != nil {
  198. t.Fatal(err)
  199. }
  200. defer fin.Close()
  201. if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info"); err != nil {
  202. t.Fatal(err)
  203. }
  204. })
  205. }
  206. }
  207. // Regression test for CVE-2025-24369
  208. func TestCVE2025_24369(t *testing.T) {
  209. pol := loadPolicies(t, "", anubis.DefaultDifficulty)
  210. srv := spawnAnubis(t, Options{
  211. Next: http.NewServeMux(),
  212. Policy: pol,
  213. })
  214. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  215. defer ts.Close()
  216. cli := httpClient(t)
  217. chall := makeChallenge(t, ts, cli)
  218. resp := handleChallengeInvalidProof(t, ts, cli, chall)
  219. if resp.StatusCode == http.StatusFound {
  220. t.Log("Regression on CVE-2025-24369")
  221. t.Errorf("wanted HTTP status %d, got: %d", http.StatusForbidden, resp.StatusCode)
  222. }
  223. }
  224. func TestCookieCustomExpiration(t *testing.T) {
  225. pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
  226. ckieExpiration := 10 * time.Minute
  227. srv := spawnAnubis(t, Options{
  228. Next: http.NewServeMux(),
  229. Policy: pol,
  230. CookieExpiration: ckieExpiration,
  231. })
  232. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  233. defer ts.Close()
  234. cli := httpClient(t)
  235. chall := makeChallenge(t, ts, cli)
  236. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  237. if resp.StatusCode != http.StatusFound {
  238. resp.Write(os.Stderr)
  239. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  240. }
  241. var ckie *http.Cookie
  242. for _, cookie := range resp.Cookies() {
  243. t.Logf("%#v", cookie)
  244. if cookie.Name == anubis.CookieName {
  245. ckie = cookie
  246. break
  247. }
  248. }
  249. if ckie == nil {
  250. t.Errorf("Cookie %q not found", anubis.CookieName)
  251. return
  252. }
  253. }
  254. func TestCookieSettings(t *testing.T) {
  255. pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
  256. srv := spawnAnubis(t, Options{
  257. Next: http.NewServeMux(),
  258. Policy: pol,
  259. CookieDomain: "127.0.0.1",
  260. CookiePartitioned: true,
  261. CookieSecure: true,
  262. CookieSameSite: http.SameSiteNoneMode,
  263. CookieExpiration: anubis.CookieDefaultExpirationTime,
  264. })
  265. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  266. defer ts.Close()
  267. cli := httpClient(t)
  268. chall := makeChallenge(t, ts, cli)
  269. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  270. if resp.StatusCode != http.StatusFound {
  271. resp.Write(os.Stderr)
  272. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  273. }
  274. var ckie *http.Cookie
  275. for _, cookie := range resp.Cookies() {
  276. t.Logf("%#v", cookie)
  277. if cookie.Name == anubis.CookieName {
  278. ckie = cookie
  279. break
  280. }
  281. }
  282. if ckie == nil {
  283. t.Errorf("Cookie %q not found", anubis.CookieName)
  284. return
  285. }
  286. if ckie.Domain != "127.0.0.1" {
  287. t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
  288. }
  289. if ckie.Partitioned != srv.opts.CookiePartitioned {
  290. t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
  291. }
  292. if ckie.Secure != srv.opts.CookieSecure {
  293. t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
  294. }
  295. if ckie.SameSite != srv.opts.CookieSameSite {
  296. t.Errorf("wanted same site option %v, got: %v", srv.opts.CookieSameSite, ckie.SameSite)
  297. }
  298. }
  299. func TestCookieSettingsSameSiteNoneModeDowngradedToLaxWhenUnsecure(t *testing.T) {
  300. pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
  301. srv := spawnAnubis(t, Options{
  302. Next: http.NewServeMux(),
  303. Policy: pol,
  304. CookieDomain: "127.0.0.1",
  305. CookiePartitioned: true,
  306. CookieSecure: false,
  307. CookieSameSite: http.SameSiteNoneMode,
  308. CookieExpiration: anubis.CookieDefaultExpirationTime,
  309. })
  310. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  311. defer ts.Close()
  312. cli := httpClient(t)
  313. chall := makeChallenge(t, ts, cli)
  314. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  315. if resp.StatusCode != http.StatusFound {
  316. resp.Write(os.Stderr)
  317. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  318. }
  319. var ckie *http.Cookie
  320. for _, cookie := range resp.Cookies() {
  321. t.Logf("%#v", cookie)
  322. if cookie.Name == anubis.CookieName {
  323. ckie = cookie
  324. break
  325. }
  326. }
  327. if ckie == nil {
  328. t.Errorf("Cookie %q not found", anubis.CookieName)
  329. return
  330. }
  331. if ckie.Domain != "127.0.0.1" {
  332. t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
  333. }
  334. if ckie.Partitioned != srv.opts.CookiePartitioned {
  335. t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
  336. }
  337. if ckie.Secure != srv.opts.CookieSecure {
  338. t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
  339. }
  340. if ckie.SameSite != http.SameSiteLaxMode {
  341. t.Errorf("wanted same site Lax option %v, got: %v", http.SameSiteLaxMode, ckie.SameSite)
  342. }
  343. }
  344. func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
  345. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  346. fmt.Fprintln(w, "OK")
  347. })
  348. for i := 1; i < 10; i++ {
  349. t.Run(fmt.Sprint(i), func(t *testing.T) {
  350. anubisPolicy := loadPolicies(t, "testdata/test_config_no_thresholds.yaml", i)
  351. s, err := New(Options{
  352. Next: h,
  353. Policy: anubisPolicy,
  354. ServeRobotsTXT: true,
  355. })
  356. if err != nil {
  357. t.Fatalf("can't construct libanubis.Server: %v", err)
  358. }
  359. req, err := http.NewRequest(http.MethodGet, "/", nil)
  360. if err != nil {
  361. t.Fatal(err)
  362. }
  363. req.Header.Add("X-Real-Ip", "127.0.0.1")
  364. cr, bot, err := s.check(req, s.logger)
  365. if err != nil {
  366. t.Fatal(err)
  367. }
  368. t.Log(cr.Name)
  369. if bot.Challenge.Difficulty != i {
  370. t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
  371. }
  372. })
  373. }
  374. }
  375. func TestBasePrefix(t *testing.T) {
  376. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  377. fmt.Fprintln(w, "OK")
  378. })
  379. testCases := []struct {
  380. name string
  381. basePrefix string
  382. path string
  383. expected string
  384. }{
  385. {
  386. name: "no prefix",
  387. basePrefix: "",
  388. path: "/.within.website/x/cmd/anubis/api/make-challenge",
  389. expected: "/.within.website/x/cmd/anubis/api/make-challenge",
  390. },
  391. {
  392. name: "with prefix",
  393. basePrefix: "/myapp",
  394. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  395. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  396. },
  397. {
  398. name: "with prefix and trailing slash",
  399. basePrefix: "/myapp/",
  400. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  401. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  402. },
  403. }
  404. for _, tc := range testCases {
  405. t.Run(tc.name, func(t *testing.T) {
  406. // Reset the global BasePrefix before each test
  407. anubis.BasePrefix = ""
  408. pol := loadPolicies(t, "", 4)
  409. srv := spawnAnubis(t, Options{
  410. Next: h,
  411. Policy: pol,
  412. BasePrefix: tc.basePrefix,
  413. })
  414. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  415. defer ts.Close()
  416. cli := httpClient(t)
  417. req, err := http.NewRequest(http.MethodPost, ts.URL+tc.path, nil)
  418. if err != nil {
  419. t.Fatal(err)
  420. }
  421. q := req.URL.Query()
  422. redir := tc.basePrefix
  423. if tc.basePrefix == "" {
  424. redir = "/"
  425. }
  426. q.Set("redir", redir)
  427. req.URL.RawQuery = q.Encode()
  428. t.Log(req.URL.String())
  429. // Test API endpoint with prefix
  430. resp, err := cli.Do(req)
  431. if err != nil {
  432. t.Fatalf("can't request challenge: %v", err)
  433. }
  434. defer resp.Body.Close()
  435. if resp.StatusCode != http.StatusOK {
  436. t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode)
  437. }
  438. data, err := io.ReadAll(resp.Body)
  439. if err != nil {
  440. t.Fatalf("can't read body: %v", err)
  441. }
  442. t.Log(string(data))
  443. var chall challengeResp
  444. if err := json.NewDecoder(bytes.NewBuffer(data)).Decode(&chall); err != nil {
  445. t.Fatalf("can't read challenge response body: %v", err)
  446. }
  447. if chall.Challenge == "" {
  448. t.Errorf("expected non-empty challenge")
  449. }
  450. // Test cookie path when passing challenge
  451. // Find a nonce that produces a hash with the required number of leading zeros
  452. nonce := 0
  453. var calculated string
  454. for {
  455. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  456. calculated = internal.SHA256sum(calcString)
  457. if strings.HasPrefix(calculated, strings.Repeat("0", pol.DefaultDifficulty)) {
  458. break
  459. }
  460. nonce++
  461. }
  462. elapsedTime := 420
  463. redir = "/"
  464. cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  465. return http.ErrUseLastResponse
  466. }
  467. // Construct the correct path for pass-challenge
  468. passChallengePath := tc.path
  469. passChallengePath = passChallengePath[:strings.LastIndex(passChallengePath, "/")+1] + "pass-challenge"
  470. req, err = http.NewRequest(http.MethodGet, ts.URL+passChallengePath, nil)
  471. if err != nil {
  472. t.Fatalf("can't make request: %v", err)
  473. }
  474. for _, ckie := range resp.Cookies() {
  475. req.AddCookie(ckie)
  476. }
  477. q = req.URL.Query()
  478. q.Set("response", calculated)
  479. q.Set("nonce", fmt.Sprint(nonce))
  480. q.Set("redir", redir)
  481. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  482. q.Set("id", chall.ID)
  483. req.URL.RawQuery = q.Encode()
  484. t.Log(req.URL.String())
  485. resp, err = cli.Do(req)
  486. if err != nil {
  487. t.Fatalf("can't do challenge passing: %v", err)
  488. }
  489. if resp.StatusCode != http.StatusFound {
  490. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  491. }
  492. // Check cookie path
  493. var ckie *http.Cookie
  494. for _, cookie := range resp.Cookies() {
  495. if cookie.Name == anubis.CookieName {
  496. ckie = cookie
  497. break
  498. }
  499. }
  500. if ckie == nil {
  501. t.Errorf("Cookie %q not found", anubis.CookieName)
  502. return
  503. }
  504. expectedPath := "/"
  505. if tc.basePrefix != "" {
  506. expectedPath = strings.TrimSuffix(tc.basePrefix, "/") + "/"
  507. }
  508. if ckie.Path != expectedPath {
  509. t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path)
  510. }
  511. })
  512. }
  513. }
  514. func TestCustomStatusCodes(t *testing.T) {
  515. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  516. t.Log(r.UserAgent())
  517. w.WriteHeader(http.StatusOK)
  518. fmt.Fprintln(w, "OK")
  519. })
  520. statusMap := map[string]int{
  521. "ALLOW": 200,
  522. "CHALLENGE": 401,
  523. "DENY": 403,
  524. }
  525. pol := loadPolicies(t, "./testdata/aggressive_403.yaml", 4)
  526. srv := spawnAnubis(t, Options{
  527. Next: h,
  528. Policy: pol,
  529. })
  530. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  531. defer ts.Close()
  532. for userAgent, statusCode := range statusMap {
  533. t.Run(userAgent, func(t *testing.T) {
  534. req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, ts.URL, nil)
  535. if err != nil {
  536. t.Fatal(err)
  537. }
  538. req.Header.Set("User-Agent", userAgent)
  539. resp, err := ts.Client().Do(req)
  540. if err != nil {
  541. t.Fatal(err)
  542. }
  543. if resp.StatusCode != statusCode {
  544. t.Errorf("wanted status code %d but got: %d", statusCode, resp.StatusCode)
  545. }
  546. })
  547. }
  548. }
  549. func TestCloudflareWorkersRule(t *testing.T) {
  550. for _, variant := range []string{"cel", "header"} {
  551. t.Run(variant, func(t *testing.T) {
  552. pol := loadPolicies(t, "./testdata/cloudflare-workers-"+variant+".yaml", 0)
  553. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  554. fmt.Fprintln(w, "OK")
  555. })
  556. s, err := New(Options{
  557. Next: h,
  558. Policy: pol,
  559. ServeRobotsTXT: true,
  560. })
  561. if err != nil {
  562. t.Fatalf("can't construct libanubis.Server: %v", err)
  563. }
  564. t.Run("with-cf-worker-header", func(t *testing.T) {
  565. req, err := http.NewRequest(http.MethodGet, "/", nil)
  566. if err != nil {
  567. t.Fatal(err)
  568. }
  569. req.Header.Add("X-Real-Ip", "127.0.0.1")
  570. req.Header.Add("Cf-Worker", "true")
  571. cr, _, err := s.check(req, s.logger)
  572. if err != nil {
  573. t.Fatal(err)
  574. }
  575. if cr.Rule != config.RuleDeny {
  576. t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleDeny, cr.Rule)
  577. }
  578. })
  579. t.Run("no-cf-worker-header", func(t *testing.T) {
  580. req, err := http.NewRequest(http.MethodGet, "/", nil)
  581. if err != nil {
  582. t.Fatal(err)
  583. }
  584. req.Header.Add("X-Real-Ip", "127.0.0.1")
  585. cr, _, err := s.check(req, s.logger)
  586. if err != nil {
  587. t.Fatal(err)
  588. }
  589. if cr.Rule != config.RuleAllow {
  590. t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleAllow, cr.Rule)
  591. }
  592. })
  593. })
  594. }
  595. }
  596. func TestRuleChange(t *testing.T) {
  597. pol := loadPolicies(t, "testdata/rule_change.yaml", 0)
  598. ckieExpiration := 10 * time.Minute
  599. srv := spawnAnubis(t, Options{
  600. Next: http.NewServeMux(),
  601. Policy: pol,
  602. CookieDomain: "127.0.0.1",
  603. CookieExpiration: ckieExpiration,
  604. })
  605. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  606. defer ts.Close()
  607. cli := httpClient(t)
  608. chall := makeChallenge(t, ts, cli)
  609. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  610. if resp.StatusCode != http.StatusFound {
  611. resp.Write(os.Stderr)
  612. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  613. }
  614. }
  615. func TestStripBasePrefixFromRequest(t *testing.T) {
  616. testCases := []struct {
  617. name string
  618. basePrefix string
  619. requestPath string
  620. expectedPath string
  621. stripBasePrefix bool
  622. }{
  623. {
  624. name: "strip disabled - no change",
  625. basePrefix: "/foo",
  626. stripBasePrefix: false,
  627. requestPath: "/foo/bar",
  628. expectedPath: "/foo/bar",
  629. },
  630. {
  631. name: "strip enabled - removes prefix",
  632. basePrefix: "/foo",
  633. stripBasePrefix: true,
  634. requestPath: "/foo/bar",
  635. expectedPath: "/bar",
  636. },
  637. {
  638. name: "strip enabled - root becomes slash",
  639. basePrefix: "/foo",
  640. stripBasePrefix: true,
  641. requestPath: "/foo",
  642. expectedPath: "/",
  643. },
  644. {
  645. name: "strip enabled - trailing slash on base prefix",
  646. basePrefix: "/foo/",
  647. stripBasePrefix: true,
  648. requestPath: "/foo/bar",
  649. expectedPath: "/bar",
  650. },
  651. {
  652. name: "strip enabled - no prefix match",
  653. basePrefix: "/foo",
  654. stripBasePrefix: true,
  655. requestPath: "/other/bar",
  656. expectedPath: "/other/bar",
  657. },
  658. {
  659. name: "strip enabled - empty base prefix",
  660. basePrefix: "",
  661. stripBasePrefix: true,
  662. requestPath: "/foo/bar",
  663. expectedPath: "/foo/bar",
  664. },
  665. {
  666. name: "strip enabled - nested path",
  667. basePrefix: "/app",
  668. stripBasePrefix: true,
  669. requestPath: "/app/api/v1/users",
  670. expectedPath: "/api/v1/users",
  671. },
  672. {
  673. name: "strip enabled - exact match becomes root",
  674. basePrefix: "/myapp",
  675. stripBasePrefix: true,
  676. requestPath: "/myapp/",
  677. expectedPath: "/",
  678. },
  679. }
  680. for _, tc := range testCases {
  681. t.Run(tc.name, func(t *testing.T) {
  682. srv := &Server{
  683. opts: Options{
  684. BasePrefix: tc.basePrefix,
  685. StripBasePrefix: tc.stripBasePrefix,
  686. },
  687. }
  688. req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)
  689. originalPath := req.URL.Path
  690. result := srv.stripBasePrefixFromRequest(req)
  691. if result.URL.Path != tc.expectedPath {
  692. t.Errorf("expected path %q, got %q", tc.expectedPath, result.URL.Path)
  693. }
  694. // Ensure original request is not modified when no stripping should occur
  695. if !tc.stripBasePrefix || tc.basePrefix == "" || !strings.HasPrefix(tc.requestPath, strings.TrimSuffix(tc.basePrefix, "/")) {
  696. if result != req {
  697. t.Error("expected same request object when no modification needed")
  698. }
  699. } else {
  700. // Ensure original request is not modified when stripping occurs
  701. if req.URL.Path != originalPath {
  702. t.Error("original request was modified")
  703. }
  704. }
  705. })
  706. }
  707. }
  708. // TestChallengeFor_ErrNotFound makes sure that users with invalid challenge IDs
  709. // in the test cookie don't get rejected by the database lookup failing.
  710. func TestChallengeFor_ErrNotFound(t *testing.T) {
  711. pol := loadPolicies(t, "testdata/aggressive_403.yaml", 0)
  712. ckieExpiration := 10 * time.Minute
  713. const wrongCookie = "wrong cookie"
  714. srv := spawnAnubis(t, Options{
  715. Next: http.NewServeMux(),
  716. Policy: pol,
  717. CookieDomain: "127.0.0.1",
  718. CookieExpiration: ckieExpiration,
  719. })
  720. req := httptest.NewRequest("GET", "http://example.com/", nil)
  721. req.Header.Set("X-Real-IP", "127.0.0.1")
  722. req.Header.Set("User-Agent", "CHALLENGE")
  723. req.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: wrongCookie})
  724. w := httptest.NewRecorder()
  725. srv.maybeReverseProxyOrPage(w, req)
  726. resp := w.Result()
  727. defer resp.Body.Close()
  728. body := new(strings.Builder)
  729. _, err := io.Copy(body, resp.Body)
  730. if err != nil {
  731. t.Fatalf("reading body should not fail: %v", err)
  732. }
  733. t.Run("make sure challenge page is issued", func(t *testing.T) {
  734. if !strings.Contains(body.String(), "anubis_challenge") {
  735. t.Error("should get a challenge page")
  736. }
  737. if resp.StatusCode != http.StatusUnauthorized {
  738. t.Errorf("should get a 401 Unauthorized, got: %d", resp.StatusCode)
  739. }
  740. })
  741. t.Run("make sure that the body is not an error page", func(t *testing.T) {
  742. if strings.Contains(body.String(), "reject.webp") {
  743. t.Error("should not get an internal server error")
  744. }
  745. })
  746. t.Run("make sure new test cookie is issued", func(t *testing.T) {
  747. found := false
  748. for _, cookie := range resp.Cookies() {
  749. if cookie.Name == anubis.TestCookieName {
  750. if cookie.Value == wrongCookie {
  751. t.Error("a new challenge cookie should be issued")
  752. }
  753. found = true
  754. }
  755. }
  756. if !found {
  757. t.Error("a new test cookie should be set")
  758. }
  759. })
  760. }
  761. func TestPassChallengeXSS(t *testing.T) {
  762. pol := loadPolicies(t, "", anubis.DefaultDifficulty)
  763. srv := spawnAnubis(t, Options{
  764. Next: http.NewServeMux(),
  765. Policy: pol,
  766. })
  767. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  768. defer ts.Close()
  769. cli := httpClient(t)
  770. chall := makeChallenge(t, ts, cli)
  771. testCases := []struct {
  772. name string
  773. redir string
  774. }{
  775. {
  776. name: "javascript alert",
  777. redir: "javascript:alert('xss')",
  778. },
  779. {
  780. name: "vbscript",
  781. redir: "vbscript:msgbox(\"XSS\")",
  782. },
  783. {
  784. name: "data url",
  785. redir: "data:text/html;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=",
  786. },
  787. }
  788. t.Run("with test cookie", func(t *testing.T) {
  789. for _, tc := range testCases {
  790. t.Run(tc.name, func(t *testing.T) {
  791. nonce := 0
  792. elapsedTime := 420
  793. calculated := ""
  794. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  795. calculated = internal.SHA256sum(calcString)
  796. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  797. if err != nil {
  798. t.Fatalf("can't make request: %v", err)
  799. }
  800. q := req.URL.Query()
  801. q.Set("response", calculated)
  802. q.Set("nonce", fmt.Sprint(nonce))
  803. q.Set("redir", tc.redir)
  804. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  805. req.URL.RawQuery = q.Encode()
  806. u, err := url.Parse(ts.URL)
  807. if err != nil {
  808. t.Fatal(err)
  809. }
  810. for _, ckie := range cli.Jar.Cookies(u) {
  811. if ckie.Name == anubis.TestCookieName {
  812. req.AddCookie(ckie)
  813. }
  814. }
  815. resp, err := cli.Do(req)
  816. if err != nil {
  817. t.Fatalf("can't do request: %v", err)
  818. }
  819. body, _ := io.ReadAll(resp.Body)
  820. if bytes.Contains(body, []byte(tc.redir)) {
  821. t.Log(string(body))
  822. t.Error("found XSS in HTML body")
  823. }
  824. if resp.StatusCode != http.StatusBadRequest {
  825. t.Errorf("wanted status %d, got %d. body: %s", http.StatusBadRequest, resp.StatusCode, body)
  826. }
  827. })
  828. }
  829. })
  830. t.Run("no test cookie", func(t *testing.T) {
  831. for _, tc := range testCases {
  832. t.Run(tc.name, func(t *testing.T) {
  833. nonce := 0
  834. elapsedTime := 420
  835. calculated := ""
  836. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  837. calculated = internal.SHA256sum(calcString)
  838. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  839. if err != nil {
  840. t.Fatalf("can't make request: %v", err)
  841. }
  842. q := req.URL.Query()
  843. q.Set("response", calculated)
  844. q.Set("nonce", fmt.Sprint(nonce))
  845. q.Set("redir", tc.redir)
  846. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  847. req.URL.RawQuery = q.Encode()
  848. resp, err := cli.Do(req)
  849. if err != nil {
  850. t.Fatalf("can't do request: %v", err)
  851. }
  852. body, _ := io.ReadAll(resp.Body)
  853. if bytes.Contains(body, []byte(tc.redir)) {
  854. t.Log(string(body))
  855. t.Error("found XSS in HTML body")
  856. }
  857. if resp.StatusCode != http.StatusBadRequest {
  858. t.Errorf("wanted status %d, got %d. body: %s", http.StatusBadRequest, resp.StatusCode, body)
  859. }
  860. })
  861. }
  862. })
  863. }
  864. func TestPassChallengeNilRuleChallengeFallback(t *testing.T) {
  865. pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
  866. srv := spawnAnubis(t, Options{
  867. Next: http.NewServeMux(),
  868. Policy: pol,
  869. })
  870. allowThreshold, err := policy.ParsedThresholdFromConfig(config.Threshold{
  871. Name: "allow-all",
  872. Expression: &config.ExpressionOrList{
  873. Expression: "true",
  874. },
  875. Action: config.RuleAllow,
  876. })
  877. if err != nil {
  878. t.Fatalf("can't compile test threshold: %v", err)
  879. }
  880. srv.policy.Thresholds = []*policy.Threshold{allowThreshold}
  881. srv.policy.Bots = nil
  882. chall := challenge.Challenge{
  883. ID: "test-challenge",
  884. Method: "metarefresh",
  885. RandomData: "apple cider",
  886. IssuedAt: time.Now().Add(-5 * time.Second),
  887. Difficulty: 1,
  888. }
  889. j := store.JSON[challenge.Challenge]{Underlying: srv.store}
  890. if err := j.Set(context.Background(), "challenge:"+chall.ID, chall, time.Minute); err != nil {
  891. t.Fatalf("can't insert challenge into store: %v", err)
  892. }
  893. req := httptest.NewRequest(http.MethodGet, "https://example.com"+anubis.APIPrefix+"pass-challenge", nil)
  894. q := req.URL.Query()
  895. q.Set("redir", "/")
  896. q.Set("id", chall.ID)
  897. q.Set("challenge", chall.RandomData)
  898. req.URL.RawQuery = q.Encode()
  899. req.Header.Set("X-Real-Ip", "203.0.113.4")
  900. req.Header.Set("User-Agent", "NilChallengeTester/1.0")
  901. req.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: chall.ID})
  902. rr := httptest.NewRecorder()
  903. srv.PassChallenge(rr, req)
  904. if rr.Code != http.StatusFound {
  905. t.Fatalf("expected redirect when validating challenge, got %d", rr.Code)
  906. }
  907. }
  908. func TestXForwardedForNoDoubleComma(t *testing.T) {
  909. var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  910. w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
  911. fmt.Fprintln(w, "OK")
  912. })
  913. h = internal.XForwardedForToXRealIP(h)
  914. h = internal.XForwardedForUpdate(false, h)
  915. pol := loadPolicies(t, "testdata/permissive.yaml", 4)
  916. srv := spawnAnubis(t, Options{
  917. Next: h,
  918. Policy: pol,
  919. })
  920. ts := httptest.NewServer(srv)
  921. t.Cleanup(ts.Close)
  922. req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
  923. if err != nil {
  924. t.Fatal(err)
  925. }
  926. req.Header.Set("X-Real-Ip", "10.0.0.1")
  927. resp, err := ts.Client().Do(req)
  928. if err != nil {
  929. t.Fatal(err)
  930. }
  931. if resp.StatusCode != http.StatusOK {
  932. t.Errorf("response status is wrong, wanted %d but got: %s", http.StatusOK, resp.Status)
  933. }
  934. if xff := resp.Header.Get("X-Forwarded-For"); strings.HasPrefix(xff, ",,") {
  935. t.Errorf("X-Forwarded-For has two leading commas: %q", xff)
  936. }
  937. }