Files
remark42/backend/app/rest/api/middleware.go
T
Dmitry VerkhoturovandGitHub 1f34984dab Bump go-pkgz/rest to v1.24.0 and opt in to wildcard origins with credentials (#2157)
rest.CORS refuses "*" together with credentials since go-pkgz/rest#52, so the
bump and the option have to land together: the option does not exist in v1.22.0
and the panic fires at construction, inside routes(), which makes it a startup
failure rather than a request-time one.

The wildcard stays. The comment widget is embedded on arbitrary third-party
sites, so the set of origins is not knowable, which is why the escape hatch was
asked for upstream instead of accepting the panic. What it costs is unchanged
and now written next to the call: any site a signed-in user visits can read
authenticated responses, so state-changing requests have to keep being protected
by something other than the origin, X-XSRF-Token today.

The example module is tidied in the same commit, as it reaches go-pkgz/rest
through the replace directive and its indirect graph would otherwise keep the
old pin and fail the readonly module check in CI.

The bump also carries testify to v1.12.0, which drops go-spew and go-difflib
from the module graph.
2026-08-19 00:33:13 -05:00

361 lines
14 KiB
Go

// Package api middleware: request-scoped HTTP middlewares used by the REST router.
package api
import (
"fmt"
"net"
"net/http"
"net/mail"
"regexp"
"strings"
"time"
"github.com/didip/tollbooth/v8"
"github.com/didip/tollbooth/v8/limiter"
log "github.com/go-pkgz/lgr"
R "github.com/go-pkgz/rest"
"github.com/umputun/remark42/backend/app/rest"
"github.com/umputun/remark42/backend/app/store"
)
// ipForwardingHeaders are the request headers R.RealIP derives the client IP from.
var ipForwardingHeaders = []string{"X-Real-IP", "X-Forwarded-For", "CF-Connecting-IP"}
// realIPMiddleware derives the client IP from forwarding headers (X-Real-IP / X-Forwarded-For /
// CF-Connecting-IP) via R.RealIP, but honors those headers only for requests whose direct peer
// is one of the trusted proxies. For any other peer it drops those headers and pins RemoteAddr to
// the real socket IP, so an untrusted client can't spoof the IP that per-IP controls (rate limiting,
// vote dedup, comment IP, anonymous id) and the request log key on.
//
// With no trusted proxies configured it falls back to trusting the headers from any client (the
// historical behavior). That is spoofable by design, so operators running behind a reverse proxy
// should set --trusted-proxy to the proxy's network — see the "trusted proxy" docs.
func realIPMiddleware(trustedProxies []*net.IPNet) func(http.Handler) http.Handler {
if len(trustedProxies) == 0 {
return R.RealIP
}
return func(next http.Handler) http.Handler {
fromTrusted := R.RealIP(next) // rewrites RemoteAddr from the forwarding headers
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
peer := directPeerIP(r.RemoteAddr)
if peer != nil && cidrsContain(trustedProxies, peer) {
fromTrusted.ServeHTTP(w, r) // trusted proxy: honor the forwarding headers
return
}
// untrusted peer: drop the forwarding headers and pin RemoteAddr to the real socket IP,
// so nothing downstream can be fooled by a spoofed header (R.RealIP normalizes
// RemoteAddr to a bare IP for trusted peers; do the same here for consistency)
for _, h := range ipForwardingHeaders {
r.Header.Del(h)
}
if peer != nil {
r.RemoteAddr = peer.String()
}
next.ServeHTTP(w, r)
})
}
}
// directPeerIP extracts the IP from a "host:port" (or bare host) RemoteAddr, or nil if unparseable.
func directPeerIP(remoteAddr string) net.IP {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr // may already be a bare IP with no port
}
return net.ParseIP(host)
}
// TrustsAnyPeer reports whether the trusted-proxy list contains a catch-all (0.0.0.0/0 or ::/0),
// which trusts forwarding headers from every client and re-opens the IP-spoofing bypass.
func TrustsAnyPeer(cidrs []*net.IPNet) bool {
for _, c := range cidrs {
if ones, _ := c.Mask.Size(); ones == 0 {
return true
}
}
return false
}
// cidrsContain reports whether ip falls within any of the CIDRs.
func cidrsContain(cidrs []*net.IPNet, ip net.IP) bool {
for _, c := range cidrs {
if c.Contains(ip) {
return true
}
}
return false
}
// ParseTrustedProxies parses a list of trusted-proxy entries into CIDRs. Each entry may be a CIDR
// (e.g. 172.16.0.0/12) or a bare IP (treated as a single host). Blank entries are skipped; a
// malformed entry is a hard error so a typo can't silently disable proxy trust.
func ParseTrustedProxies(entries []string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !strings.Contains(e, "/") { // bare IP -> single-host CIDR
ip := net.ParseIP(e)
if ip == nil {
return nil, fmt.Errorf("invalid trusted proxy %q", e)
}
// build the network from the normalized IP so a v4-mapped IPv6 (e.g. ::ffff:10.0.0.1)
// yields the intended /32 host, not a huge ::/32 range
bits := 128
if v4 := ip.To4(); v4 != nil {
ip, bits = v4, 32
}
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
continue
}
_, network, err := net.ParseCIDR(e)
if err != nil {
return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", e, err)
}
out = append(out, network)
}
return out, nil
}
// corsMiddleware builds the CORS middleware for the public API. With AllowedOrigins
// "*" and credentials enabled, rest.CORS reflects the request Origin into
// Access-Control-Allow-Origin (rather than a literal "*"), which browsers require
// for credentialed cross-origin requests.
//
// That combination is refused by default upstream, so it has to be asked for by name with
// CorsUnsafeAnyOriginWithCredentials. The wildcard stays because the comment widget is embedded on
// arbitrary third-party sites, which makes the set of origins unknowable. The consequence it carries
// is that any site a signed-in user visits can read authenticated responses, so state-changing
// requests have to keep being protected by something other than the origin, X-XSRF-Token today.
func corsMiddleware() func(http.Handler) http.Handler {
return R.CORS(
R.CorsAllowedOrigins("*"),
R.CorsAllowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"),
R.CorsAllowedHeaders("Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"),
R.CorsExposedHeaders("Authorization"),
R.CorsAllowCredentials(true),
R.CorsUnsafeAnyOriginWithCredentials(true),
R.CorsMaxAge(300),
)
}
// rejectHead rejects HEAD requests with 405, advertising the given allowed methods in
// the Allow header. net/http.ServeMux routes HEAD to a "GET ..." handler, but per RFC
// 9110 GET/HEAD are safe methods; this guard is applied to the few GET routes whose
// handlers mutate state so they cannot be triggered by a (nominally side-effect-free)
// HEAD, preserving the pre-routegroup behavior. allow lists every method the resource
// supports (e.g. "GET" or "GET, POST") so the 405 Allow header is accurate.
func rejectHead(allow string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.Header().Set("Allow", allow)
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
next.ServeHTTP(w, r)
})
}
}
// rejectAnonUser is a middleware rejecting anonymous users
func rejectAnonUser(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if strings.HasPrefix(user.ID, "anonymous_") {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
func matchSiteID(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// skip for basic auth user
if user.Name == "admin" && user.ID == "admin" {
next.ServeHTTP(w, r)
return
}
siteID := r.URL.Query().Get("site")
// require an explicit site so the user.SiteID check below cannot be bypassed
// by simply omitting the query parameter
if siteID == "" || user.SiteID != siteID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// cacheControl is a middleware setting cache expiration. Using url+version as etag
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
etag := func(r *http.Request, version string) string {
s := version + ":" + r.URL.String()
return store.EncodeID(s)
}
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
e := `"` + etag(r, version) + `"`
w.Header().Set("Etag", e)
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, e) {
w.WriteHeader(http.StatusNotModified)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes
// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps
// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages
// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or
// images — none of those should ever execute scripts when rendered, so they get the
// strictest policy available as defense-in-depth against future trust-boundary bugs.
//
// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same
// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition:
// inline; filename="image" (framing the response as a file rather than a renderable
// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and-
// braces: if a future route refactor bypasses this middleware, the image handlers still
// emit the policy.
func apiCSPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", rest.StrictImageCSP)
next.ServeHTTP(w, r)
})
}
// securityHeadersMiddleware sets security-related headers:
// - Content-Security-Policy: controls which resources the browser is allowed to load
// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget
// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type,
// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS
// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin
// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains
// and nothing at all on HTTPS→HTTP downgrades
func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
imgSrc := "*"
if imageProxyEnabled {
imgSrc = "'self'"
}
frameAncestors := "*"
if len(allowedAncestors) > 0 {
frameAncestors = strings.Join(allowedAncestors, " ")
}
// font-src is set to 'none' (no @font-face / no base64 fonts in the bundle).
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
}
// subscribersOnly is a middleware rejecting non-paid_sub users
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if enable {
user, err := rest.GetUserInfo(r)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !user.PaidSub {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// validEmailAuth is a middleware for auth endpoints for email method.
// it rejects login request if user, site or email are suspicious
func validEmailAuth() func(http.Handler) http.Handler {
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/email/login" {
// not email login, skip the check
h.ServeHTTP(w, r)
return
}
if u := r.URL.Query().Get("user"); u != "" {
if !reUser.MatchString(u) {
log.Printf("[WARN] suspicious user rejected: %s", u)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
if a := r.URL.Query().Get("address"); a != "" {
if _, err := mail.ParseAddress(a); err != nil {
log.Printf("[WARN] suspicious address rejected: %s", a)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
if s := r.URL.Query().Get("site"); s != "" {
if !reSite.MatchString(s) {
log.Printf("[WARN] suspicious site rejected: %s", s)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
// tollbooth v8 requires explicit IP lookup method to be set.
// keys on RemoteAddr, which realIPMiddleware sets to the client IP (from the forwarding
// headers for trusted proxies, otherwise the real socket IP).
func rateLimiter(maxReq float64) func(http.Handler) http.Handler {
lmt := tollbooth.NewLimiter(maxReq, nil)
lmt.SetIPLookup(limiter.IPLookup{
Name: "RemoteAddr",
IndexFromRight: 0,
})
return tollbooth.HTTPMiddleware(lmt)
}