http.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. package lib
  2. import (
  3. "bytes"
  4. "compress/gzip"
  5. "encoding/base64"
  6. "errors"
  7. "fmt"
  8. "math/rand"
  9. "net/http"
  10. "net/url"
  11. "regexp"
  12. "strings"
  13. "time"
  14. "github.com/TecharoHQ/anubis"
  15. "github.com/TecharoHQ/anubis/internal"
  16. "github.com/TecharoHQ/anubis/internal/glob"
  17. "github.com/TecharoHQ/anubis/lib/challenge"
  18. "github.com/TecharoHQ/anubis/lib/localization"
  19. "github.com/TecharoHQ/anubis/lib/policy"
  20. "github.com/TecharoHQ/anubis/web"
  21. "github.com/TecharoHQ/anubis/xess"
  22. "github.com/a-h/templ"
  23. "github.com/golang-jwt/jwt/v5"
  24. "golang.org/x/net/publicsuffix"
  25. )
  26. var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
  27. var (
  28. ErrActualAnubisBug = errors.New("this is an actual bug in Anubis, please file an issue with the magic string 'taco bell'")
  29. )
  30. // matchRedirectDomain returns true if host matches any of the allowed redirect
  31. // domain patterns. Patterns may contain '*' which are matched using the
  32. // internal glob matcher. Matching is case-insensitive on hostnames.
  33. func matchRedirectDomain(allowed []string, host string) bool {
  34. h := strings.ToLower(strings.TrimSpace(host))
  35. for _, pat := range allowed {
  36. p := strings.ToLower(strings.TrimSpace(pat))
  37. if strings.Contains(p, glob.GLOB) {
  38. if glob.Glob(p, h) {
  39. return true
  40. }
  41. continue
  42. }
  43. if p == h {
  44. return true
  45. }
  46. }
  47. return false
  48. }
  49. type CookieOpts struct {
  50. Value string
  51. Host string
  52. Path string
  53. Name string
  54. Expiry time.Duration
  55. }
  56. func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
  57. var domain = s.opts.CookieDomain
  58. var name = anubis.CookieName
  59. var path = "/"
  60. var sameSite = s.opts.CookieSameSite
  61. if cookieOpts.Name != "" {
  62. name = cookieOpts.Name
  63. }
  64. if cookieOpts.Path != "" {
  65. path = cookieOpts.Path
  66. }
  67. if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
  68. if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
  69. domain = etld
  70. }
  71. }
  72. if cookieOpts.Expiry == 0 {
  73. cookieOpts.Expiry = s.opts.CookieExpiration
  74. }
  75. if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
  76. sameSite = http.SameSiteLaxMode
  77. }
  78. http.SetCookie(w, &http.Cookie{
  79. Name: name,
  80. Value: cookieOpts.Value,
  81. Expires: time.Now().Add(cookieOpts.Expiry),
  82. SameSite: sameSite,
  83. Domain: domain,
  84. Secure: s.opts.CookieSecure,
  85. Partitioned: s.opts.CookiePartitioned,
  86. Path: path,
  87. })
  88. }
  89. func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
  90. var domain = s.opts.CookieDomain
  91. var name = anubis.CookieName
  92. var path = "/"
  93. var sameSite = s.opts.CookieSameSite
  94. if cookieOpts.Name != "" {
  95. name = cookieOpts.Name
  96. }
  97. if cookieOpts.Path != "" {
  98. path = cookieOpts.Path
  99. }
  100. if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
  101. if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
  102. domain = etld
  103. }
  104. }
  105. if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
  106. sameSite = http.SameSiteLaxMode
  107. }
  108. http.SetCookie(w, &http.Cookie{
  109. Name: name,
  110. Value: "",
  111. MaxAge: -1,
  112. Expires: time.Now().Add(-1 * time.Minute),
  113. SameSite: sameSite,
  114. Partitioned: s.opts.CookiePartitioned,
  115. Domain: domain,
  116. Secure: s.opts.CookieSecure,
  117. Path: path,
  118. })
  119. }
  120. // https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
  121. type UnixRoundTripper struct {
  122. Transport *http.Transport
  123. }
  124. // set bare minimum stuff
  125. func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
  126. req = req.Clone(req.Context())
  127. if req.Host == "" {
  128. req.Host = "localhost"
  129. }
  130. req.URL.Host = req.Host // proxy error: no Host in request URL
  131. req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
  132. return t.Transport.RoundTrip(req)
  133. }
  134. func randomChance(n int) bool {
  135. return rand.Intn(n) == 0
  136. }
  137. // XXX(Xe): generated by ChatGPT
  138. func rot13(s string) string {
  139. rotated := make([]rune, len(s))
  140. for i, c := range s {
  141. switch {
  142. case c >= 'A' && c <= 'Z':
  143. rotated[i] = 'A' + ((c - 'A' + 13) % 26)
  144. case c >= 'a' && c <= 'z':
  145. rotated[i] = 'a' + ((c - 'a' + 13) % 26)
  146. default:
  147. rotated[i] = c
  148. }
  149. }
  150. return string(rotated)
  151. }
  152. func makeCode(err error) string {
  153. var buf bytes.Buffer
  154. gzw := gzip.NewWriter(&buf)
  155. errStr := fmt.Sprintf("internal error: %v", err)
  156. fmt.Fprintln(gzw, rot13(errStr))
  157. if err := gzw.Close(); err != nil {
  158. panic("can't write to gzip in ram buffer")
  159. }
  160. const width = 16
  161. enc := base64.StdEncoding.EncodeToString(buf.Bytes())
  162. var builder strings.Builder
  163. for i := 0; i < len(enc); i += width {
  164. end := i + width
  165. if end > len(enc) {
  166. end = len(enc)
  167. }
  168. builder.WriteString(enc[i:end])
  169. builder.WriteByte('\n')
  170. }
  171. return builder.String()
  172. }
  173. func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) {
  174. localizer := localization.GetLocalizer(r)
  175. if returnHTTPStatusOnly {
  176. if s.opts.PublicUrl == "" {
  177. w.WriteHeader(http.StatusUnauthorized)
  178. w.Write([]byte(localizer.T("authorization_required")))
  179. } else {
  180. redirectURL, err := s.constructRedirectURL(r)
  181. if err != nil {
  182. s.respondWithStatus(w, r, err.Error(), "", http.StatusBadRequest)
  183. return
  184. }
  185. http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
  186. }
  187. return
  188. }
  189. lg := internal.GetRequestLogger(s.logger, r)
  190. if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
  191. lg.Error("client was given a challenge but does not in fact support gzip compression")
  192. s.respondWithError(w, r, localizer.T("client_error_browser"), "")
  193. return
  194. }
  195. challengesIssued.WithLabelValues("embedded").Add(1)
  196. chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
  197. if err != nil {
  198. lg.Error("can't get challenge", "err", err)
  199. s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
  200. s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
  201. return
  202. }
  203. lg = lg.With("challenge", chall.ID)
  204. var ogTags map[string]string = nil
  205. if s.opts.OpenGraph.Enabled {
  206. var err error
  207. ogTags, err = s.OGTags.GetOGTags(r.Context(), r.URL, r.Host)
  208. if err != nil {
  209. lg.Error("failed to get OG tags", "err", err)
  210. }
  211. }
  212. s.SetCookie(w, CookieOpts{
  213. Value: chall.ID,
  214. Host: r.Host,
  215. Path: "/",
  216. Name: anubis.TestCookieName,
  217. Expiry: 30 * time.Minute,
  218. })
  219. impl, ok := challenge.Get(chall.Method)
  220. if !ok {
  221. lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
  222. s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
  223. s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
  224. return
  225. }
  226. in := &challenge.IssueInput{
  227. Impressum: s.policy.Impressum,
  228. Rule: rule,
  229. Challenge: chall,
  230. OGTags: ogTags,
  231. Store: s.store,
  232. }
  233. component, err := impl.Issue(w, r, lg, in)
  234. if err != nil {
  235. lg.Error("[unexpected] challenge component render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this.
  236. s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")), makeCode(err))
  237. return
  238. }
  239. page := web.BaseWithChallengeAndOGTags(
  240. localizer.T("making_sure_not_bot"),
  241. component,
  242. s.policy.Impressum,
  243. chall,
  244. in.Rule.Challenge,
  245. in.OGTags,
  246. localizer,
  247. )
  248. handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(
  249. page,
  250. templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
  251. )))
  252. handler.ServeHTTP(w, r)
  253. }
  254. func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
  255. proto := r.Header.Get("X-Forwarded-Proto")
  256. host := r.Header.Get("X-Forwarded-Host")
  257. uri := r.Header.Get("X-Forwarded-Uri")
  258. localizer := localization.GetLocalizer(r)
  259. if proto == "" || host == "" || uri == "" {
  260. return "", errors.New(localizer.T("missing_required_forwarded_headers"))
  261. }
  262. switch proto {
  263. case "http", "https":
  264. // allowed
  265. default:
  266. lg := internal.GetRequestLogger(s.logger, r)
  267. lg.Warn("invalid protocol in X-Forwarded-Proto", "proto", proto)
  268. return "", errors.New(localizer.T("invalid_redirect"))
  269. }
  270. // Check if host is allowed in RedirectDomains (supports '*' via glob)
  271. if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {
  272. lg := internal.GetRequestLogger(s.logger, r)
  273. lg.Debug("domain not allowed", "domain", host)
  274. return "", errors.New(localizer.T("redirect_domain_not_allowed"))
  275. }
  276. redir := proto + "://" + host + uri
  277. escapedURL := url.QueryEscape(redir)
  278. return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil
  279. }
  280. func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
  281. localizer := localization.GetLocalizer(r)
  282. templ.Handler(
  283. web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer),
  284. ).ServeHTTP(w, r)
  285. }
  286. func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) {
  287. s.respondWithStatus(w, r, message, code, http.StatusInternalServerError)
  288. }
  289. func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) {
  290. localizer := localization.GetLocalizer(r)
  291. templ.Handler(web.Base(localizer.T("oh_noes"), web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer), s.policy.Impressum, localizer), templ.WithStatus(status)).ServeHTTP(w, r)
  292. }
  293. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  294. if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+anubis.StaticPath) {
  295. s.mux.ServeHTTP(w, r)
  296. return
  297. } else if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+xess.BasePrefix) {
  298. s.mux.ServeHTTP(w, r)
  299. return
  300. }
  301. // Forward robots.txt requests to mux when ServeRobotsTXT is enabled
  302. if s.opts.ServeRobotsTXT {
  303. path := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix)
  304. if path == "/robots.txt" || path == "/.well-known/robots.txt" {
  305. s.mux.ServeHTTP(w, r)
  306. return
  307. }
  308. }
  309. s.maybeReverseProxyOrPage(w, r)
  310. }
  311. func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {
  312. if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" {
  313. return r
  314. }
  315. basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/")
  316. path := r.URL.Path
  317. if !strings.HasPrefix(path, basePrefix) {
  318. return r
  319. }
  320. trimmedPath := strings.TrimPrefix(path, basePrefix)
  321. if trimmedPath == "" {
  322. trimmedPath = "/"
  323. }
  324. // Clone the request and URL
  325. reqCopy := r.Clone(r.Context())
  326. urlCopy := *r.URL
  327. urlCopy.Path = trimmedPath
  328. reqCopy.URL = &urlCopy
  329. return reqCopy
  330. }
  331. func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
  332. if s.next == nil {
  333. localizer := localization.GetLocalizer(r)
  334. redir := r.FormValue("redir")
  335. urlParsed, err := url.ParseRequestURI(redir)
  336. if err != nil {
  337. // if ParseRequestURI fails, try as relative URL
  338. urlParsed, err = r.URL.Parse(redir)
  339. if err != nil {
  340. s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
  341. return
  342. }
  343. }
  344. // validate URL scheme to prevent javascript:, data:, file:, tel:, etc.
  345. switch urlParsed.Scheme {
  346. case "", "http", "https":
  347. // allowed: empty scheme means relative URL
  348. default:
  349. lg := internal.GetRequestLogger(s.logger, r)
  350. lg.Warn("XSS attempt blocked, invalid redirect scheme", "scheme", urlParsed.Scheme, "redir", redir)
  351. s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
  352. return
  353. }
  354. hostNotAllowed := len(urlParsed.Host) > 0 &&
  355. len(s.opts.RedirectDomains) != 0 &&
  356. !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)
  357. hostMismatch := r.URL.Host != "" && urlParsed.Host != "" && urlParsed.Host != r.URL.Host
  358. if hostNotAllowed || hostMismatch {
  359. lg := internal.GetRequestLogger(s.logger, r)
  360. lg.Debug("domain not allowed", "domain", urlParsed.Host)
  361. s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), makeCode(err), http.StatusBadRequest)
  362. return
  363. }
  364. if redir != "" {
  365. http.Redirect(w, r, redir, http.StatusFound)
  366. return
  367. }
  368. templ.Handler(
  369. web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
  370. ).ServeHTTP(w, r)
  371. } else {
  372. requestsProxied.WithLabelValues(r.Host).Inc()
  373. r = s.stripBasePrefixFromRequest(r)
  374. s.next.ServeHTTP(w, r)
  375. }
  376. }
  377. func (s *Server) signJWT(claims jwt.MapClaims) (string, error) {
  378. claims["iat"] = time.Now().Unix()
  379. claims["nbf"] = time.Now().Add(-1 * time.Minute).Unix()
  380. claims["exp"] = time.Now().Add(s.opts.CookieExpiration).Unix()
  381. if len(s.hs512Secret) == 0 {
  382. return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(s.ed25519Priv)
  383. } else {
  384. return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(s.hs512Secret)
  385. }
  386. }