* test(nginx-external-auth): bring up to code standards Signed-off-by: Xe Iaso <me@xeiaso.net> * fix(lib): close open redirect when in subrequest mode Closes GHSA-cf57-c578-7jvv Previously Anubis had an open redirect in subrequest auth mode due to an insufficent fix in GHSA-jhjj-2g64-px7c. This patch adds additional validation at several steps of the flow to prevent open redirects in subrequest auth mode as well as implements automated testing to prevent this from occuring in the future. * docs: update CHANGELOG Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net>
440 lines
12 KiB
Go
440 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"))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|