* feat(lib/challenge): expose ResponseWriter to challenge issuers Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(metarefresh): randomly use the Refresh header There are several ways to trigger an automatic refresh without JavaScript. One of them is the "meta refresh" method[1], but the other is with the Refresh header[2]. Both are semantically identical and supported with browsers as old as Chrome version 1. Given that they are basically the same thing, this patch makes Anubis randomly select between them by using the challenge random data's first character. This will fire about 50% of the time. I expect this to have no impact. If this works out fine, then I will implement some kind of fallback logic for the fast challenge such that admins can opt into allowing clients with a no-js configuration to pass the fast challenge. This needs to bake in the oven though. [1]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/http-equiv [2]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh Signed-off-by: Xe Iaso <me@xeiaso.net> * docs: update CHANGELOG Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(metarefresh): simplify random logic Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net> Signed-off-by: Xe Iaso <xe.iaso@techaro.lol>
359 lines
10 KiB
Go
359 lines
10 KiB
Go
package lib
|
|
|
|
import (
|
|
"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/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,}$`)
|
|
|
|
// 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
|
|
}
|
|
|
|
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))
|
|
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))
|
|
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")))
|
|
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"))
|
|
}
|
|
// 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 string) {
|
|
s.respondWithStatus(w, r, message, http.StatusInternalServerError)
|
|
}
|
|
|
|
func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg string, status int) {
|
|
localizer := localization.GetLocalizer(r)
|
|
|
|
templ.Handler(web.Base(localizer.T("oh_noes"), web.ErrorPage(msg, s.opts.WebmasterEmail, localizer), s.policy.Impressum, localizer), templ.WithStatus(status)).ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(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 := r.URL.Parse(redir)
|
|
if err != nil {
|
|
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), 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 != 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"), 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)
|
|
}
|
|
}
|