Files
anubis-mirror/lib/http.go
T
Timon de GrootandGitHub 5db8b5c452 feat: add HttpOnly cookie option (#1679)
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]>
2026-06-11 11:36:07 -04:00

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)
}
}