| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- package lib
- import (
- "bytes"
- "compress/gzip"
- "encoding/base64"
- "errors"
- "fmt"
- "math/rand"
- "net/http"
- "net/url"
- "regexp"
- "strings"
- "time"
- "github.com/TecharoHQ/anubis"
- "github.com/TecharoHQ/anubis/internal"
- "github.com/TecharoHQ/anubis/internal/glob"
- "github.com/TecharoHQ/anubis/lib/challenge"
- "github.com/TecharoHQ/anubis/lib/localization"
- "github.com/TecharoHQ/anubis/lib/policy"
- "github.com/TecharoHQ/anubis/web"
- "github.com/TecharoHQ/anubis/xess"
- "github.com/a-h/templ"
- "github.com/golang-jwt/jwt/v5"
- "golang.org/x/net/publicsuffix"
- )
- var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
- var (
- ErrActualAnubisBug = errors.New("this is an actual bug in Anubis, please file an issue with the magic string 'taco bell'")
- )
- // matchRedirectDomain returns true if host matches any of the allowed redirect
- // domain patterns. Patterns may contain '*' which are matched using the
- // internal glob matcher. Matching is case-insensitive on hostnames.
- func matchRedirectDomain(allowed []string, host string) bool {
- h := strings.ToLower(strings.TrimSpace(host))
- for _, pat := range allowed {
- p := strings.ToLower(strings.TrimSpace(pat))
- if strings.Contains(p, glob.GLOB) {
- if glob.Glob(p, h) {
- return true
- }
- continue
- }
- if p == h {
- return true
- }
- }
- return false
- }
- type CookieOpts struct {
- Value string
- Host string
- Path string
- Name string
- Expiry time.Duration
- }
- func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
- var domain = s.opts.CookieDomain
- var name = anubis.CookieName
- var path = "/"
- var sameSite = s.opts.CookieSameSite
- if cookieOpts.Name != "" {
- name = cookieOpts.Name
- }
- if cookieOpts.Path != "" {
- path = cookieOpts.Path
- }
- if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
- if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
- domain = etld
- }
- }
- if cookieOpts.Expiry == 0 {
- cookieOpts.Expiry = s.opts.CookieExpiration
- }
- if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
- sameSite = http.SameSiteLaxMode
- }
- http.SetCookie(w, &http.Cookie{
- Name: name,
- Value: cookieOpts.Value,
- Expires: time.Now().Add(cookieOpts.Expiry),
- SameSite: sameSite,
- Domain: domain,
- Secure: s.opts.CookieSecure,
- Partitioned: s.opts.CookiePartitioned,
- Path: path,
- })
- }
- func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
- var domain = s.opts.CookieDomain
- var name = anubis.CookieName
- var path = "/"
- var sameSite = s.opts.CookieSameSite
- if cookieOpts.Name != "" {
- name = cookieOpts.Name
- }
- if cookieOpts.Path != "" {
- path = cookieOpts.Path
- }
- if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
- if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
- domain = etld
- }
- }
- if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
- sameSite = http.SameSiteLaxMode
- }
- http.SetCookie(w, &http.Cookie{
- Name: name,
- Value: "",
- MaxAge: -1,
- Expires: time.Now().Add(-1 * time.Minute),
- SameSite: sameSite,
- Partitioned: s.opts.CookiePartitioned,
- Domain: domain,
- Secure: s.opts.CookieSecure,
- Path: path,
- })
- }
- // https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
- type UnixRoundTripper struct {
- Transport *http.Transport
- }
- // set bare minimum stuff
- func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- req = req.Clone(req.Context())
- if req.Host == "" {
- req.Host = "localhost"
- }
- req.URL.Host = req.Host // proxy error: no Host in request URL
- req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
- return t.Transport.RoundTrip(req)
- }
- func randomChance(n int) bool {
- return rand.Intn(n) == 0
- }
- // XXX(Xe): generated by ChatGPT
- func rot13(s string) string {
- rotated := make([]rune, len(s))
- for i, c := range s {
- switch {
- case c >= 'A' && c <= 'Z':
- rotated[i] = 'A' + ((c - 'A' + 13) % 26)
- case c >= 'a' && c <= 'z':
- rotated[i] = 'a' + ((c - 'a' + 13) % 26)
- default:
- rotated[i] = c
- }
- }
- return string(rotated)
- }
- func makeCode(err error) string {
- var buf bytes.Buffer
- gzw := gzip.NewWriter(&buf)
- errStr := fmt.Sprintf("internal error: %v", err)
- fmt.Fprintln(gzw, rot13(errStr))
- if err := gzw.Close(); err != nil {
- panic("can't write to gzip in ram buffer")
- }
- const width = 16
- enc := base64.StdEncoding.EncodeToString(buf.Bytes())
- var builder strings.Builder
- for i := 0; i < len(enc); i += width {
- end := i + width
- if end > len(enc) {
- end = len(enc)
- }
- builder.WriteString(enc[i:end])
- builder.WriteByte('\n')
- }
- return builder.String()
- }
- func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, rule *policy.Bot, returnHTTPStatusOnly bool) {
- localizer := localization.GetLocalizer(r)
- if returnHTTPStatusOnly {
- if s.opts.PublicUrl == "" {
- w.WriteHeader(http.StatusUnauthorized)
- w.Write([]byte(localizer.T("authorization_required")))
- } else {
- redirectURL, err := s.constructRedirectURL(r)
- if err != nil {
- s.respondWithStatus(w, r, err.Error(), "", http.StatusBadRequest)
- return
- }
- http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
- }
- return
- }
- lg := internal.GetRequestLogger(s.logger, r)
- if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
- lg.Error("client was given a challenge but does not in fact support gzip compression")
- s.respondWithError(w, r, localizer.T("client_error_browser"), "")
- return
- }
- challengesIssued.WithLabelValues("embedded").Add(1)
- chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
- if err != nil {
- lg.Error("can't get challenge", "err", err)
- s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
- s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
- return
- }
- lg = lg.With("challenge", chall.ID)
- var ogTags map[string]string = nil
- if s.opts.OpenGraph.Enabled {
- var err error
- ogTags, err = s.OGTags.GetOGTags(r.Context(), r.URL, r.Host)
- if err != nil {
- lg.Error("failed to get OG tags", "err", err)
- }
- }
- s.SetCookie(w, CookieOpts{
- Value: chall.ID,
- Host: r.Host,
- Path: "/",
- Name: anubis.TestCookieName,
- Expiry: 30 * time.Minute,
- })
- impl, ok := challenge.Get(chall.Method)
- if !ok {
- lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
- s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
- s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm), makeCode(err))
- return
- }
- in := &challenge.IssueInput{
- Impressum: s.policy.Impressum,
- Rule: rule,
- Challenge: chall,
- OGTags: ogTags,
- Store: s.store,
- }
- component, err := impl.Issue(w, r, lg, in)
- if err != nil {
- 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.
- s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")), makeCode(err))
- return
- }
- page := web.BaseWithChallengeAndOGTags(
- localizer.T("making_sure_not_bot"),
- component,
- s.policy.Impressum,
- chall,
- in.Rule.Challenge,
- in.OGTags,
- localizer,
- )
- handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(
- page,
- templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
- )))
- handler.ServeHTTP(w, r)
- }
- func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
- proto := r.Header.Get("X-Forwarded-Proto")
- host := r.Header.Get("X-Forwarded-Host")
- uri := r.Header.Get("X-Forwarded-Uri")
- localizer := localization.GetLocalizer(r)
- if proto == "" || host == "" || uri == "" {
- return "", errors.New(localizer.T("missing_required_forwarded_headers"))
- }
- switch proto {
- case "http", "https":
- // allowed
- default:
- lg := internal.GetRequestLogger(s.logger, r)
- lg.Warn("invalid protocol in X-Forwarded-Proto", "proto", proto)
- return "", errors.New(localizer.T("invalid_redirect"))
- }
- // Check if host is allowed in RedirectDomains (supports '*' via glob)
- if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {
- lg := internal.GetRequestLogger(s.logger, r)
- lg.Debug("domain not allowed", "domain", host)
- return "", errors.New(localizer.T("redirect_domain_not_allowed"))
- }
- redir := proto + "://" + host + uri
- escapedURL := url.QueryEscape(redir)
- return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil
- }
- func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
- localizer := localization.GetLocalizer(r)
- templ.Handler(
- web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer),
- ).ServeHTTP(w, r)
- }
- func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message, code string) {
- s.respondWithStatus(w, r, message, code, http.StatusInternalServerError)
- }
- func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg, code string, status int) {
- localizer := localization.GetLocalizer(r)
- 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)
- }
- func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+anubis.StaticPath) {
- s.mux.ServeHTTP(w, r)
- return
- } else if strings.HasPrefix(r.URL.Path, anubis.BasePrefix+xess.BasePrefix) {
- s.mux.ServeHTTP(w, r)
- return
- }
- // Forward robots.txt requests to mux when ServeRobotsTXT is enabled
- if s.opts.ServeRobotsTXT {
- path := strings.TrimPrefix(r.URL.Path, anubis.BasePrefix)
- if path == "/robots.txt" || path == "/.well-known/robots.txt" {
- s.mux.ServeHTTP(w, r)
- return
- }
- }
- s.maybeReverseProxyOrPage(w, r)
- }
- func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {
- if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" {
- return r
- }
- basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/")
- path := r.URL.Path
- if !strings.HasPrefix(path, basePrefix) {
- return r
- }
- trimmedPath := strings.TrimPrefix(path, basePrefix)
- if trimmedPath == "" {
- trimmedPath = "/"
- }
- // Clone the request and URL
- reqCopy := r.Clone(r.Context())
- urlCopy := *r.URL
- urlCopy.Path = trimmedPath
- reqCopy.URL = &urlCopy
- return reqCopy
- }
- func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
- if s.next == nil {
- localizer := localization.GetLocalizer(r)
- redir := r.FormValue("redir")
- urlParsed, err := url.ParseRequestURI(redir)
- if err != nil {
- // if ParseRequestURI fails, try as relative URL
- urlParsed, err = r.URL.Parse(redir)
- if err != nil {
- s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
- return
- }
- }
- // validate URL scheme to prevent javascript:, data:, file:, tel:, etc.
- switch urlParsed.Scheme {
- case "", "http", "https":
- // allowed: empty scheme means relative URL
- default:
- lg := internal.GetRequestLogger(s.logger, r)
- lg.Warn("XSS attempt blocked, invalid redirect scheme", "scheme", urlParsed.Scheme, "redir", redir)
- s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", http.StatusBadRequest)
- return
- }
- hostNotAllowed := len(urlParsed.Host) > 0 &&
- len(s.opts.RedirectDomains) != 0 &&
- !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)
- hostMismatch := r.URL.Host != "" && urlParsed.Host != "" && urlParsed.Host != r.URL.Host
- if hostNotAllowed || hostMismatch {
- lg := internal.GetRequestLogger(s.logger, r)
- lg.Debug("domain not allowed", "domain", urlParsed.Host)
- s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), makeCode(err), http.StatusBadRequest)
- return
- }
- if redir != "" {
- http.Redirect(w, r, redir, http.StatusFound)
- return
- }
- templ.Handler(
- web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
- ).ServeHTTP(w, r)
- } else {
- requestsProxied.WithLabelValues(r.Host).Inc()
- r = s.stripBasePrefixFromRequest(r)
- s.next.ServeHTTP(w, r)
- }
- }
- func (s *Server) signJWT(claims jwt.MapClaims) (string, error) {
- claims["iat"] = time.Now().Unix()
- claims["nbf"] = time.Now().Add(-1 * time.Minute).Unix()
- claims["exp"] = time.Now().Add(s.opts.CookieExpiration).Unix()
- if len(s.hs512Secret) == 0 {
- return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(s.ed25519Priv)
- } else {
- return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(s.hs512Secret)
- }
- }
|