mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-07-22 05:12:19 +00:00
This adds support for enabling the HttpOnly flag for cookies. Setting this default option value to false makes it a conservative, backwards-compatible change. By using the HttpOnly flag, sites stay working even when using strict cookie consent management tools, which is frequently used in EU sites to comply with GDPR and ePrivacy directive. The tests for setting cookies have been merged into a single table-driven test structure, adding a test case for toggling the HttpOnly option, while also adding a proper assertion for the custom expiration option. Assisted-by: Claude Opus 4.8 via Claude Code Signed-off-by: Timon de Groot <[email protected]>
469 lines
13 KiB
Go
469 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,
|
|
HttpOnly: s.opts.CookieHttpOnly,
|
|
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,
|
|
HttpOnly: s.opts.CookieHttpOnly,
|
|
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 := min(i+width, 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, r := s.getRequestLogger(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
|
|
}
|
|
|
|
{
|
|
asn, asnDesc := asnFromContext(r.Context())
|
|
challengesIssued.WithLabelValues("embedded", asn, asnDesc).Add(1)
|
|
}
|
|
chall, err := s.issueChallenge(r.Context(), r, lg, cr, rule)
|
|
if err != nil {
|
|
lg.Error("can't get challenge", "err", err)
|
|
algorithm := "unknown"
|
|
if rule.Challenge != nil {
|
|
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"), 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 {
|
|
algorithm := "unknown"
|
|
if rule.Challenge != nil {
|
|
algorithm = rule.Challenge.Algorithm
|
|
}
|
|
lg.Error("check failed", "err", "can't get algorithm", "algorithm", algorithm)
|
|
s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
|
|
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), 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, _ := s.getRequestLogger(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, _ := s.getRequestLogger(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.Parse(redir)
|
|
if err != nil {
|
|
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), makeCode(err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if urlParsed.Opaque != "" || (urlParsed.Scheme == "" && strings.HasPrefix(redir, "//")) {
|
|
s.respondWithStatus(w, r, localizer.T("invalid_redirect"), "", 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, _ := s.getRequestLogger(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, _ := s.getRequestLogger(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 {
|
|
asn, asnDesc := asnFromContext(r.Context())
|
|
requestsProxied.WithLabelValues(r.Host, asn, asnDesc).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)
|
|
}
|
|
}
|