* fix(lib): show error message detail when hitting some common flows Instead of giving the user nothing to go off of, this patch gives them an opaque blob of ROT-13 encoded base64. The logic is that if you are smart enough to figure out how to decode this, you're probably smart enough to either fix your broken client or give it to the adminstrator. Signed-off-by: Xe Iaso <me@xeiaso.net> * docs: update CHANGELOG Signed-off-by: Xe Iaso <me@xeiaso.net> * Update metadata check-spelling run (pull_request) for Xe/show-error-state Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com> on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev> --------- Signed-off-by: Xe Iaso <me@xeiaso.net> Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
415 lines
12 KiB
Go
415 lines
12 KiB
Go
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"))
|
|
}
|
|
// 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
|
|
}
|
|
|
|
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 := r.URL.Parse(redir)
|
|
if err != nil {
|
|
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), 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"), 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)
|
|
}
|
|
}
|