mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-04-05 08:18:17 +00:00
* refactor(http): split long line in respondWithStatus Signed-off-by: Max Chernoff <git@maxchernoff.ca> * feat(http): set `Cache-Control: no-store` on error responses Since #132, Anubis has set `Cache-Control: no-store` on challenge responses. However, this does not apply to deny responses, meaning that if Anubis is configured to block certain user agents and is behind a caching reverse proxy, this error page will be cached and served to all subsequent requests, even those with an allowed user agent. This commit configures the error page responder to also set the `Cache-Control` header, meaning that deny and challenge responses will now both have the same behaviour. Signed-off-by: Max Chernoff <git@maxchernoff.ca> * chore(spelling): add new words to allowlist Signed-off-by: Max Chernoff <git@maxchernoff.ca> * chore(actions): bump Go version to fix govulncheck errors Signed-off-by: Max Chernoff <git@maxchernoff.ca> --------- Signed-off-by: Max Chernoff <git@maxchernoff.ca> Signed-off-by: Xe Iaso <xe.iaso@techaro.lol> Co-authored-by: Xe Iaso <xe.iaso@techaro.lol>
457 lines
13 KiB
Go
457 lines
13 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"))
|
|
}
|
|
|
|
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)
|
|
|
|
component := web.Base(
|
|
localizer.T("oh_noes"),
|
|
web.ErrorPage(msg, s.opts.WebmasterEmail, code, localizer),
|
|
s.policy.Impressum,
|
|
localizer,
|
|
)
|
|
handler := internal.NoStoreCache(templ.Handler(component, templ.WithStatus(status)))
|
|
handler.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)
|
|
}
|
|
}
|