anubis_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
  1. package lib
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "net/http/httptest"
  7. "net/url"
  8. "os"
  9. "strings"
  10. "sync"
  11. "testing"
  12. "time"
  13. "github.com/TecharoHQ/anubis"
  14. "github.com/TecharoHQ/anubis/data"
  15. "github.com/TecharoHQ/anubis/internal"
  16. "github.com/TecharoHQ/anubis/internal/thoth/thothmock"
  17. "github.com/TecharoHQ/anubis/lib/policy"
  18. "github.com/TecharoHQ/anubis/lib/policy/config"
  19. )
  20. func init() {
  21. internal.InitSlog("debug")
  22. }
  23. func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConfig {
  24. t.Helper()
  25. ctx := thothmock.WithMockThoth(t)
  26. if fname == "" {
  27. fname = "./testdata/test_config.yaml"
  28. }
  29. anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty)
  30. if err != nil {
  31. t.Fatal(err)
  32. }
  33. return anubisPolicy
  34. }
  35. func spawnAnubis(t *testing.T, opts Options) *Server {
  36. t.Helper()
  37. s, err := New(opts)
  38. if err != nil {
  39. t.Fatalf("can't construct libanubis.Server: %v", err)
  40. }
  41. return s
  42. }
  43. type challengeResp struct {
  44. Challenge string `json:"challenge"`
  45. }
  46. func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challengeResp {
  47. t.Helper()
  48. req, err := http.NewRequest(http.MethodPost, ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", nil)
  49. if err != nil {
  50. t.Fatalf("can't make request: %v", err)
  51. }
  52. q := req.URL.Query()
  53. q.Set("redir", "/")
  54. req.URL.RawQuery = q.Encode()
  55. resp, err := cli.Do(req)
  56. if err != nil {
  57. t.Fatalf("can't request challenge: %v", err)
  58. }
  59. defer resp.Body.Close()
  60. var chall challengeResp
  61. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  62. t.Fatalf("can't read challenge response body: %v", err)
  63. }
  64. return chall
  65. }
  66. func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {
  67. t.Helper()
  68. nonce := 0
  69. elapsedTime := 420
  70. redir := "/"
  71. calculated := ""
  72. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  73. calculated = internal.SHA256sum(calcString)
  74. req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
  75. if err != nil {
  76. t.Fatalf("can't make request: %v", err)
  77. }
  78. q := req.URL.Query()
  79. q.Set("response", calculated)
  80. q.Set("nonce", fmt.Sprint(nonce))
  81. q.Set("redir", redir)
  82. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  83. req.URL.RawQuery = q.Encode()
  84. resp, err := cli.Do(req)
  85. if err != nil {
  86. t.Fatalf("can't do request: %v", err)
  87. }
  88. return resp
  89. }
  90. type loggingCookieJar struct {
  91. t *testing.T
  92. lock sync.Mutex
  93. cookies map[string][]*http.Cookie
  94. }
  95. func (lcj *loggingCookieJar) Cookies(u *url.URL) []*http.Cookie {
  96. lcj.lock.Lock()
  97. defer lcj.lock.Unlock()
  98. // XXX(Xe): This is not RFC compliant in the slightest.
  99. result, ok := lcj.cookies[u.Host]
  100. if !ok {
  101. return nil
  102. }
  103. lcj.t.Logf("requested cookies for %s", u)
  104. for _, ckie := range result {
  105. lcj.t.Logf("get cookie: <- %s", ckie)
  106. }
  107. return result
  108. }
  109. func (lcj *loggingCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
  110. lcj.lock.Lock()
  111. defer lcj.lock.Unlock()
  112. for _, ckie := range cookies {
  113. lcj.t.Logf("set cookie: %s -> %s", u, ckie)
  114. }
  115. // XXX(Xe): This is not RFC compliant in the slightest.
  116. lcj.cookies[u.Host] = append(lcj.cookies[u.Host], cookies...)
  117. }
  118. func httpClient(t *testing.T) *http.Client {
  119. t.Helper()
  120. cli := &http.Client{
  121. Jar: &loggingCookieJar{t: t, cookies: map[string][]*http.Cookie{}},
  122. CheckRedirect: func(req *http.Request, via []*http.Request) error {
  123. return http.ErrUseLastResponse
  124. },
  125. }
  126. return cli
  127. }
  128. func TestLoadPolicies(t *testing.T) {
  129. for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
  130. t.Run(fname, func(t *testing.T) {
  131. fin, err := data.BotPolicies.Open(fname)
  132. if err != nil {
  133. t.Fatal(err)
  134. }
  135. defer fin.Close()
  136. if _, err := policy.ParseConfig(t.Context(), fin, fname, 4); err != nil {
  137. t.Fatal(err)
  138. }
  139. })
  140. }
  141. }
  142. // Regression test for CVE-2025-24369
  143. func TestCVE2025_24369(t *testing.T) {
  144. pol := loadPolicies(t, "", anubis.DefaultDifficulty)
  145. srv := spawnAnubis(t, Options{
  146. Next: http.NewServeMux(),
  147. Policy: pol,
  148. CookieName: t.Name(),
  149. })
  150. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  151. defer ts.Close()
  152. cli := httpClient(t)
  153. chall := makeChallenge(t, ts, cli)
  154. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  155. if resp.StatusCode == http.StatusFound {
  156. t.Log("Regression on CVE-2025-24369")
  157. t.Errorf("wanted HTTP status %d, got: %d", http.StatusForbidden, resp.StatusCode)
  158. }
  159. }
  160. func TestCookieCustomExpiration(t *testing.T) {
  161. pol := loadPolicies(t, "", 0)
  162. ckieExpiration := 10 * time.Minute
  163. srv := spawnAnubis(t, Options{
  164. Next: http.NewServeMux(),
  165. Policy: pol,
  166. CookieExpiration: ckieExpiration,
  167. })
  168. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  169. defer ts.Close()
  170. cli := httpClient(t)
  171. chall := makeChallenge(t, ts, cli)
  172. requestReceiveLowerBound := time.Now().Add(-1 * time.Minute)
  173. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  174. requestReceiveUpperBound := time.Now()
  175. if resp.StatusCode != http.StatusFound {
  176. resp.Write(os.Stderr)
  177. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  178. }
  179. var ckie *http.Cookie
  180. for _, cookie := range resp.Cookies() {
  181. t.Logf("%#v", cookie)
  182. if cookie.Name == srv.cookieName {
  183. ckie = cookie
  184. break
  185. }
  186. }
  187. if ckie == nil {
  188. t.Errorf("Cookie %q not found", srv.cookieName)
  189. return
  190. }
  191. expirationLowerBound := requestReceiveLowerBound.Add(ckieExpiration)
  192. expirationUpperBound := requestReceiveUpperBound.Add(ckieExpiration)
  193. // Since the cookie expiration precision is only to the second due to the Unix() call, we can
  194. // lower the level of expected precision.
  195. if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
  196. t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
  197. return
  198. }
  199. }
  200. func TestCookieSettings(t *testing.T) {
  201. pol := loadPolicies(t, "", 0)
  202. srv := spawnAnubis(t, Options{
  203. Next: http.NewServeMux(),
  204. Policy: pol,
  205. CookieDomain: "127.0.0.1",
  206. CookiePartitioned: true,
  207. CookieName: t.Name(),
  208. CookieExpiration: anubis.CookieDefaultExpirationTime,
  209. })
  210. requestReceiveLowerBound := time.Now()
  211. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  212. defer ts.Close()
  213. cli := httpClient(t)
  214. chall := makeChallenge(t, ts, cli)
  215. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  216. requestReceiveUpperBound := time.Now()
  217. if resp.StatusCode != http.StatusFound {
  218. resp.Write(os.Stderr)
  219. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  220. }
  221. var ckie *http.Cookie
  222. for _, cookie := range resp.Cookies() {
  223. t.Logf("%#v", cookie)
  224. if cookie.Name == srv.cookieName {
  225. ckie = cookie
  226. break
  227. }
  228. }
  229. if ckie == nil {
  230. t.Errorf("Cookie %q not found", srv.cookieName)
  231. return
  232. }
  233. if ckie.Domain != "127.0.0.1" {
  234. t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
  235. }
  236. expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
  237. expirationUpperBound := requestReceiveUpperBound.Add(anubis.CookieDefaultExpirationTime)
  238. // Since the cookie expiration precision is only to the second due to the Unix() call, we can
  239. // lower the level of expected precision.
  240. if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
  241. t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
  242. return
  243. }
  244. if ckie.Partitioned != srv.opts.CookiePartitioned {
  245. t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
  246. }
  247. }
  248. func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
  249. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  250. fmt.Fprintln(w, "OK")
  251. })
  252. for i := 1; i < 10; i++ {
  253. t.Run(fmt.Sprint(i), func(t *testing.T) {
  254. anubisPolicy := loadPolicies(t, "", i)
  255. s, err := New(Options{
  256. Next: h,
  257. Policy: anubisPolicy,
  258. ServeRobotsTXT: true,
  259. })
  260. if err != nil {
  261. t.Fatalf("can't construct libanubis.Server: %v", err)
  262. }
  263. req, err := http.NewRequest(http.MethodGet, "/", nil)
  264. if err != nil {
  265. t.Fatal(err)
  266. }
  267. req.Header.Add("X-Real-Ip", "127.0.0.1")
  268. cr, bot, err := s.check(req)
  269. if err != nil {
  270. t.Fatal(err)
  271. }
  272. t.Log(cr.Name)
  273. if bot.Challenge.Difficulty != i {
  274. t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
  275. }
  276. if bot.Challenge.ReportAs != i {
  277. t.Errorf("Challenge.ReportAs is wrong, wanted %d, got: %d", i, bot.Challenge.ReportAs)
  278. }
  279. })
  280. }
  281. }
  282. func TestBasePrefix(t *testing.T) {
  283. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  284. fmt.Fprintln(w, "OK")
  285. })
  286. testCases := []struct {
  287. name string
  288. basePrefix string
  289. path string
  290. expected string
  291. }{
  292. {
  293. name: "no prefix",
  294. basePrefix: "/",
  295. path: "/.within.website/x/cmd/anubis/api/make-challenge",
  296. expected: "/.within.website/x/cmd/anubis/api/make-challenge",
  297. },
  298. {
  299. name: "with prefix",
  300. basePrefix: "/myapp",
  301. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  302. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  303. },
  304. {
  305. name: "with prefix and trailing slash",
  306. basePrefix: "/myapp/",
  307. path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  308. expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
  309. },
  310. }
  311. for _, tc := range testCases {
  312. t.Run(tc.name, func(t *testing.T) {
  313. // Reset the global BasePrefix before each test
  314. anubis.BasePrefix = ""
  315. pol := loadPolicies(t, "", 4)
  316. srv := spawnAnubis(t, Options{
  317. Next: h,
  318. Policy: pol,
  319. BasePrefix: tc.basePrefix,
  320. })
  321. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  322. defer ts.Close()
  323. cli := httpClient(t)
  324. req, err := http.NewRequest(http.MethodPost, ts.URL+tc.path, nil)
  325. if err != nil {
  326. t.Fatal(err)
  327. }
  328. q := req.URL.Query()
  329. q.Set("redir", tc.basePrefix)
  330. req.URL.RawQuery = q.Encode()
  331. // Test API endpoint with prefix
  332. resp, err := cli.Do(req)
  333. if err != nil {
  334. t.Fatalf("can't request challenge: %v", err)
  335. }
  336. defer resp.Body.Close()
  337. if resp.StatusCode != http.StatusOK {
  338. t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode)
  339. }
  340. var chall challengeResp
  341. if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
  342. t.Fatalf("can't read challenge response body: %v", err)
  343. }
  344. if chall.Challenge == "" {
  345. t.Errorf("expected non-empty challenge")
  346. }
  347. // Test cookie path when passing challenge
  348. // Find a nonce that produces a hash with the required number of leading zeros
  349. nonce := 0
  350. var calculated string
  351. for {
  352. calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
  353. calculated = internal.SHA256sum(calcString)
  354. if strings.HasPrefix(calculated, strings.Repeat("0", pol.DefaultDifficulty)) {
  355. break
  356. }
  357. nonce++
  358. }
  359. elapsedTime := 420
  360. redir := "/"
  361. cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  362. return http.ErrUseLastResponse
  363. }
  364. // Construct the correct path for pass-challenge
  365. passChallengePath := tc.path
  366. passChallengePath = passChallengePath[:strings.LastIndex(passChallengePath, "/")+1] + "pass-challenge"
  367. req, err = http.NewRequest(http.MethodGet, ts.URL+passChallengePath, nil)
  368. if err != nil {
  369. t.Fatalf("can't make request: %v", err)
  370. }
  371. for _, ckie := range resp.Cookies() {
  372. req.AddCookie(ckie)
  373. }
  374. q = req.URL.Query()
  375. q.Set("response", calculated)
  376. q.Set("nonce", fmt.Sprint(nonce))
  377. q.Set("redir", redir)
  378. q.Set("elapsedTime", fmt.Sprint(elapsedTime))
  379. req.URL.RawQuery = q.Encode()
  380. resp, err = cli.Do(req)
  381. if err != nil {
  382. t.Fatalf("can't do challenge passing: %v", err)
  383. }
  384. if resp.StatusCode != http.StatusFound {
  385. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  386. }
  387. // Check cookie path
  388. var ckie *http.Cookie
  389. for _, cookie := range resp.Cookies() {
  390. if cookie.Name == anubis.CookieName {
  391. ckie = cookie
  392. break
  393. }
  394. }
  395. if ckie == nil {
  396. t.Errorf("Cookie %q not found", anubis.CookieName)
  397. return
  398. }
  399. expectedPath := "/"
  400. if tc.basePrefix != "" {
  401. expectedPath = strings.TrimSuffix(tc.basePrefix, "/") + "/"
  402. }
  403. if ckie.Path != expectedPath {
  404. t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path)
  405. }
  406. })
  407. }
  408. }
  409. func TestCustomStatusCodes(t *testing.T) {
  410. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  411. t.Log(r.UserAgent())
  412. w.WriteHeader(http.StatusOK)
  413. fmt.Fprintln(w, "OK")
  414. })
  415. statusMap := map[string]int{
  416. "ALLOW": 200,
  417. "CHALLENGE": 401,
  418. "DENY": 403,
  419. }
  420. pol := loadPolicies(t, "./testdata/aggressive_403.yaml", 4)
  421. srv := spawnAnubis(t, Options{
  422. Next: h,
  423. Policy: pol,
  424. })
  425. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  426. defer ts.Close()
  427. for userAgent, statusCode := range statusMap {
  428. t.Run(userAgent, func(t *testing.T) {
  429. req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, ts.URL, nil)
  430. if err != nil {
  431. t.Fatal(err)
  432. }
  433. req.Header.Set("User-Agent", userAgent)
  434. resp, err := ts.Client().Do(req)
  435. if err != nil {
  436. t.Fatal(err)
  437. }
  438. if resp.StatusCode != statusCode {
  439. t.Errorf("wanted status code %d but got: %d", statusCode, resp.StatusCode)
  440. }
  441. })
  442. }
  443. }
  444. func TestCloudflareWorkersRule(t *testing.T) {
  445. for _, variant := range []string{"cel", "header"} {
  446. t.Run(variant, func(t *testing.T) {
  447. pol := loadPolicies(t, "./testdata/cloudflare-workers-"+variant+".yaml", 0)
  448. h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  449. fmt.Fprintln(w, "OK")
  450. })
  451. s, err := New(Options{
  452. Next: h,
  453. Policy: pol,
  454. ServeRobotsTXT: true,
  455. })
  456. if err != nil {
  457. t.Fatalf("can't construct libanubis.Server: %v", err)
  458. }
  459. t.Run("with-cf-worker-header", func(t *testing.T) {
  460. req, err := http.NewRequest(http.MethodGet, "/", nil)
  461. if err != nil {
  462. t.Fatal(err)
  463. }
  464. req.Header.Add("X-Real-Ip", "127.0.0.1")
  465. req.Header.Add("Cf-Worker", "true")
  466. cr, _, err := s.check(req)
  467. if err != nil {
  468. t.Fatal(err)
  469. }
  470. if cr.Rule != config.RuleDeny {
  471. t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleDeny, cr.Rule)
  472. }
  473. })
  474. t.Run("no-cf-worker-header", func(t *testing.T) {
  475. req, err := http.NewRequest(http.MethodGet, "/", nil)
  476. if err != nil {
  477. t.Fatal(err)
  478. }
  479. req.Header.Add("X-Real-Ip", "127.0.0.1")
  480. cr, _, err := s.check(req)
  481. if err != nil {
  482. t.Fatal(err)
  483. }
  484. if cr.Rule != config.RuleAllow {
  485. t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleAllow, cr.Rule)
  486. }
  487. })
  488. })
  489. }
  490. }
  491. func TestRuleChange(t *testing.T) {
  492. pol := loadPolicies(t, "testdata/rule_change.yaml", 0)
  493. ckieExpiration := 10 * time.Minute
  494. srv := spawnAnubis(t, Options{
  495. Next: http.NewServeMux(),
  496. Policy: pol,
  497. CookieDomain: "127.0.0.1",
  498. CookieName: t.Name(),
  499. CookieExpiration: ckieExpiration,
  500. })
  501. ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
  502. defer ts.Close()
  503. cli := httpClient(t)
  504. chall := makeChallenge(t, ts, cli)
  505. resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
  506. if resp.StatusCode != http.StatusFound {
  507. resp.Write(os.Stderr)
  508. t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
  509. }
  510. }
  511. func TestStripBasePrefixFromRequest(t *testing.T) {
  512. testCases := []struct {
  513. name string
  514. basePrefix string
  515. stripBasePrefix bool
  516. requestPath string
  517. expectedPath string
  518. }{
  519. {
  520. name: "strip disabled - no change",
  521. basePrefix: "/foo",
  522. stripBasePrefix: false,
  523. requestPath: "/foo/bar",
  524. expectedPath: "/foo/bar",
  525. },
  526. {
  527. name: "strip enabled - removes prefix",
  528. basePrefix: "/foo",
  529. stripBasePrefix: true,
  530. requestPath: "/foo/bar",
  531. expectedPath: "/bar",
  532. },
  533. {
  534. name: "strip enabled - root becomes slash",
  535. basePrefix: "/foo",
  536. stripBasePrefix: true,
  537. requestPath: "/foo",
  538. expectedPath: "/",
  539. },
  540. {
  541. name: "strip enabled - trailing slash on base prefix",
  542. basePrefix: "/foo/",
  543. stripBasePrefix: true,
  544. requestPath: "/foo/bar",
  545. expectedPath: "/bar",
  546. },
  547. {
  548. name: "strip enabled - no prefix match",
  549. basePrefix: "/foo",
  550. stripBasePrefix: true,
  551. requestPath: "/other/bar",
  552. expectedPath: "/other/bar",
  553. },
  554. {
  555. name: "strip enabled - empty base prefix",
  556. basePrefix: "",
  557. stripBasePrefix: true,
  558. requestPath: "/foo/bar",
  559. expectedPath: "/foo/bar",
  560. },
  561. {
  562. name: "strip enabled - nested path",
  563. basePrefix: "/app",
  564. stripBasePrefix: true,
  565. requestPath: "/app/api/v1/users",
  566. expectedPath: "/api/v1/users",
  567. },
  568. {
  569. name: "strip enabled - exact match becomes root",
  570. basePrefix: "/myapp",
  571. stripBasePrefix: true,
  572. requestPath: "/myapp/",
  573. expectedPath: "/",
  574. },
  575. }
  576. for _, tc := range testCases {
  577. t.Run(tc.name, func(t *testing.T) {
  578. srv := &Server{
  579. opts: Options{
  580. BasePrefix: tc.basePrefix,
  581. StripBasePrefix: tc.stripBasePrefix,
  582. },
  583. }
  584. req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)
  585. originalPath := req.URL.Path
  586. result := srv.stripBasePrefixFromRequest(req)
  587. if result.URL.Path != tc.expectedPath {
  588. t.Errorf("expected path %q, got %q", tc.expectedPath, result.URL.Path)
  589. }
  590. // Ensure original request is not modified when no stripping should occur
  591. if !tc.stripBasePrefix || tc.basePrefix == "" || !strings.HasPrefix(tc.requestPath, strings.TrimSuffix(tc.basePrefix, "/")) {
  592. if result != req {
  593. t.Error("expected same request object when no modification needed")
  594. }
  595. } else {
  596. // Ensure original request is not modified when stripping occurs
  597. if req.URL.Path != originalPath {
  598. t.Error("original request was modified")
  599. }
  600. }
  601. })
  602. }
  603. }