fix: add --trusted-proxy to gate client-IP forwarding headers

Rate limiting and (with --votes-ip) vote de-duplication key on the client IP,
recovered from forwarding headers (X-Real-IP / X-Forwarded-For / CF-Connecting-IP)
when behind a reverse proxy. Those headers were accepted from any client, so a
caller could set them to change its apparent IP.

Add --trusted-proxy / TRUSTED_PROXY (comma-separated CIDR/IP): forwarding headers
are honored only when the direct peer is a trusted proxy; other peers keep their
real socket address. Unset preserves the previous trust-all behavior (with a
startup warning) so existing deployments keep working on upgrade.

Docs: a 'Trusted proxies and client IP' section with per-topology guidance, plus a
note in the nginx manual.
This commit is contained in:
Dmitry Verkhoturov
2026-07-05 17:47:19 -05:00
committed by Umputun
parent 2e3a680ca4
commit b1502801fa
8 changed files with 236 additions and 3 deletions
+13
View File
@@ -85,6 +85,7 @@ type ServerCommand struct {
Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"`
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
TrustedProxies []string `long:"trusted-proxy" env:"TRUSTED_PROXY" description:"reverse-proxy networks (CIDR or IP) whose X-Real-IP/X-Forwarded-For headers set the client IP; if unset, these headers are trusted from any client (see docs)" env-delim:","`
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
@@ -596,6 +597,17 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
}
log.Printf("[INFO] root url=%s", s.RemarkURL)
// parse trusted proxies up front so a bad CIDR fails before any resource is allocated
trustedProxies, err := api.ParseTrustedProxies(s.TrustedProxies)
if err != nil {
return nil, fmt.Errorf("invalid --trusted-proxy: %w", err)
}
if len(trustedProxies) == 0 {
log.Printf("[WARN] --trusted-proxy is not set: X-Real-IP/X-Forwarded-For/CF-Connecting-IP headers are trusted from " +
"any client, so a client can spoof its IP and bypass rate limiting and vote dedup. Behind a reverse proxy set " +
"--trusted-proxy to the proxy network (e.g. 172.16.0.0/12 for a proxy in the same compose); see the trusted-proxy docs.")
}
storeEngine, err := s.makeDataStore()
if err != nil {
return nil, fmt.Errorf("failed to make data store engine: %w", err)
@@ -703,6 +715,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
Migrator: migr,
ReadOnlyAge: s.ReadOnlyAge,
SharedSecret: s.SharedSecret,
TrustedProxies: trustedProxies,
Authenticator: authenticator,
Cache: loadingCache,
NotifyService: notifyService,
+10
View File
@@ -377,6 +377,16 @@ func TestServerApp_Failed(t *testing.T) {
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
t.Log(err)
// invalid trusted proxy CIDR fails fast, before any resource is created
opts = ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
p = flags.NewParser(&opts, flags.Default)
_, err = p.ParseArgs([]string{"--backup=/tmp", "--trusted-proxy=nonsense"})
assert.NoError(t, err)
_, err = opts.newServerApp(context.Background())
assert.EqualError(t, err, `invalid --trusted-proxy: invalid trusted proxy "nonsense"`)
t.Log(err)
// wrong store type
opts = ServerCommand{}
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
+93 -2
View File
@@ -3,6 +3,7 @@ package api
import (
"fmt"
"net"
"net/http"
"net/mail"
"regexp"
@@ -17,6 +18,96 @@ import (
"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)
}
// 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
@@ -239,8 +330,8 @@ func validEmailAuth() func(http.Handler) http.Handler {
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
// tollbooth v8 requires explicit IP lookup method to be set.
// uses RemoteAddr which is set by rest.RealIP to the real client IP
// from X-Forwarded-For, X-Real-IP, or True-Client-IP headers.
// 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{
+67
View File
@@ -2,6 +2,7 @@ package api
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"strconv"
@@ -49,6 +50,72 @@ func TestRouteTimeout(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode, "route without R.Timeout runs to completion")
}
func TestRealIPMiddleware(t *testing.T) {
var seenAddr, seenHdr string // what the downstream handler observes
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seenAddr, seenHdr = r.RemoteAddr, r.Header.Get("X-Real-IP")
})
call := func(mw func(http.Handler) http.Handler, remoteAddr, xRealIP string) {
seenAddr, seenHdr = "", ""
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req.RemoteAddr = remoteAddr
req.Header.Set("X-Real-IP", xRealIP)
mw(next).ServeHTTP(httptest.NewRecorder(), req)
}
trusted, err := ParseTrustedProxies([]string{"172.16.0.0/12", "2001:db8::/32"})
require.NoError(t, err)
t.Run("no trusted proxies trusts the header from anyone (legacy)", func(t *testing.T) {
call(realIPMiddleware(nil), "203.0.113.9:1234", "8.8.8.8")
assert.Equal(t, "8.8.8.8", seenAddr)
})
t.Run("trusted v4 peer: forwarding header sets the client IP", func(t *testing.T) {
call(realIPMiddleware(trusted), "172.18.0.5:5555", "8.8.8.8")
assert.Equal(t, "8.8.8.8", seenAddr)
})
t.Run("trusted v6 peer: forwarding header honored", func(t *testing.T) {
call(realIPMiddleware(trusted), "[2001:db8::5]:5555", "8.8.8.8")
assert.Equal(t, "8.8.8.8", seenAddr)
})
t.Run("untrusted peer: header stripped, RemoteAddr pinned to bare socket IP", func(t *testing.T) {
call(realIPMiddleware(trusted), "203.0.113.9:1234", "8.8.8.8")
assert.Equal(t, "203.0.113.9", seenAddr, "real socket IP with the port stripped")
assert.Empty(t, seenHdr, "spoofed forwarding header removed so nothing downstream can read it")
})
}
func TestParseTrustedProxies(t *testing.T) {
t.Run("cidr, bare v4, bare v6, blanks", func(t *testing.T) {
got, err := ParseTrustedProxies([]string{"172.16.0.0/12", " 10.0.0.1 ", "", "2001:db8::/32"})
require.NoError(t, err)
require.Len(t, got, 3)
assert.True(t, got[0].Contains(net.ParseIP("172.18.0.5")))
assert.True(t, got[1].Contains(net.ParseIP("10.0.0.1")))
assert.False(t, got[1].Contains(net.ParseIP("10.0.0.2")), "a bare IP is a single host")
assert.True(t, got[2].Contains(net.ParseIP("2001:db8::1")))
})
t.Run("v4-mapped IPv6 bare entry resolves to the v4 host", func(t *testing.T) {
got, err := ParseTrustedProxies([]string{"::ffff:10.0.0.1"})
require.NoError(t, err)
require.Len(t, got, 1)
assert.True(t, got[0].Contains(net.ParseIP("10.0.0.1")), "the intended /32 host")
assert.False(t, got[0].Contains(net.ParseIP("10.0.0.2")), "not a wider range")
})
t.Run("malformed entry is a hard error", func(t *testing.T) {
_, err := ParseTrustedProxies([]string{"172.16.0.0/12", "nonsense"})
require.Error(t, err)
_, err = ParseTrustedProxies([]string{"10.0.0.0/999"})
require.Error(t, err)
})
t.Run("all blank yields nil", func(t *testing.T) {
got, err := ParseTrustedProxies([]string{"", " "})
require.NoError(t, err)
assert.Empty(t, got)
})
}
func TestRest_rejectAnonUser(t *testing.T) {
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "Hello")
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"strings"
@@ -48,6 +49,7 @@ type Rest struct {
RemarkURL string
ReadOnlyAge int
SharedSecret string
TrustedProxies []*net.IPNet // reverse-proxy networks whose forwarding headers (X-Real-IP, X-Forwarded-For, ...) are trusted
ScoreThresholds struct {
Low int
Critical int
@@ -215,7 +217,7 @@ func (s *Rest) routes() http.Handler {
s.openRouteLimiter = openRouteLimiter
}
router := routegroup.New(http.NewServeMux())
router.Use(R.Throttle(1000), R.RealIP, R.Recoverer(log.Default()))
router.Use(R.Throttle(1000), realIPMiddleware(s.TrustedProxies), R.Recoverer(log.Default()))
router.Use(securityHeadersMiddleware(s.ExternalImageProxy, s.AllowedAncestors))
if !s.DisableSignature {
router.Use(R.AppInfo("remark42", "umputun", s.Version))
@@ -171,6 +171,7 @@ services:
| port | REMARK_PORT | `8080` | web server port |
| web-root | REMARK_WEB_ROOT | `./web` | web server root directory |
| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit |
| trusted-proxy | TRUSTED_PROXY | none (trust any) | reverse-proxy networks (CIDR/IP, comma-separated) trusted to set the client IP; see [Trusted proxies and client IP](#trusted-proxies-and-client-ip) |
| subscribers-only | SUBSCRIBERS_ONLY | `false` | enable commenting only for Patreon subscribers |
| disable-signature | DISABLE_SIGNATURE | `false` | disable server signature in headers |
| disable-fancy-text-formatting | DISABLE_FANCY_HTML_FORMATTING | `false` | disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc) |
@@ -202,6 +203,51 @@ This configuration should only be used when:
2. You understand and accept the increased XSS risk
3. You have implemented strong XSS protections on your site
### Trusted proxies and client IP
Remark42 keys per-IP rate limiting — and, when `--votes-ip` is enabled, vote de-duplication and the stored comment IP — on the client IP. When Remark42 runs behind a reverse proxy (nginx, Reproxy, Traefik, Cloudflare, an ALB, a k8s ingress, …) the TCP connection it sees comes from the **proxy**, not the visitor, so the proxy forwards the real client IP in a header and Remark42 reads it (priority: `X-Real-IP`, then `CF-Connecting-IP`, then `X-Forwarded-For`) to recover the real IP.
> ⚠️ **Security note.** Those headers can be set by any client. If Remark42 trusts them from everyone, a caller can send `X-Real-IP: <anything>` and rotate its apparent IP to **bypass rate limiting and vote de-duplication**. Use `--trusted-proxy` so Remark42 reads the forwarding headers **only** when the request actually arrives from your proxy.
`--trusted-proxy` / `TRUSTED_PROXY` takes a comma-separated list of networks (CIDR) or bare IPs. When set, the forwarding headers are honored only if the **direct peer** — the machine that opened the TCP connection to Remark42 — falls inside one of them; for a request from any other peer those headers are dropped and the real socket IP is used. When it is **not** set, the headers are trusted from any client: this preserves the historical behavior so existing deployments keep working, but leaves the bypass above open, and Remark42 prints a warning at startup. **If Remark42 is reachable from the internet, set this.**
Two conditions must **both** hold to be safe:
**1. Trust the right peer.** Point `--trusted-proxy` at the network your proxy connects _from_, and nothing wider.
**2. Your proxy must set the IP itself.** Trusting a proxy is not enough if the proxy relays what the client sent. Because Remark42 reads `X-Real-IP` first, the proxy must **set** `X-Real-IP` to the real connecting client and overwrite any client value. A proxy that only _appends_ to `X-Forwarded-For` (Traefik, most cloud ALBs, k8s ingress, HAProxy defaults) or that derives `X-Real-IP` from a client-controlled `X-Forwarded-For` leaves the visitor in control of the reported IP **even through a trusted proxy**:
- **nginx** — safe with the [manual's](../../manuals/nginx/) `proxy_set_header X-Real-IP $remote_addr;` (overwrites any client value).
- **Reproxy** — sets `X-Real-IP`, but derives it from an incoming `X-Forwarded-For` when present, so a directly-exposed Reproxy still lets a client choose it; front it with something that strips client `X-Forwarded-For`, or don't rely on it for per-IP controls.
- **Cloudflare** — does **not** set `X-Real-IP`, and a client-supplied `X-Real-IP` wins over `CF-Connecting-IP`; add a Cloudflare Transform Rule that sets `X-Real-IP` from `CF-Connecting-IP` and clears any inbound `X-Real-IP` / `X-Forwarded-For`.
**Recommended `--trusted-proxy` per deployment:**
| Deployment | Direct peer Remark42 sees | `--trusted-proxy` |
| --- | --- | --- |
| nginx in the **same Docker Compose** (the [nginx manual](../../manuals/nginx/)) | the proxy container's Docker IP | the Docker network, e.g. `172.16.0.0/12` (covers Docker's default bridge pool) — or pin your compose network's subnet |
| reverse proxy on the **host** / another machine | the proxy's host IP | that proxy's IP or CIDR, e.g. `10.0.0.5` |
| **Directly exposed**, no proxy | the real client IP already | `--trusted-proxy=127.0.0.1/32` — a value no client matches, so forwarding headers are always ignored and the real connecting IP is used |
Examples:
```
# docker-compose: nginx on the same network
TRUSTED_PROXY=172.16.0.0/12
# a single upstream proxy
--trusted-proxy=10.0.0.5
# multiple ranges (v4 and v6)
TRUSTED_PROXY=10.0.0.0/8,fd00::/8
```
Caveats:
- **IPv6.** CIDRs are matched by family — `172.16.0.0/12` (or `0.0.0.0/0`) never matches an IPv6 peer. If your proxy reaches Remark42 over IPv6 (dual-stack / IPv6-enabled Docker), add the IPv6 network too, or every visitor collapses onto the proxy's IP and gets over-throttled.
- **Don't publish Remark42's own port** when trusting a Docker range. If Remark42's port is exposed to the host, external traffic is SNAT'd to the Docker gateway (inside `172.16.0.0/12`) and appears trusted — re-opening the bypass. Publish only the proxy.
- `0.0.0.0/0` trusts everyone and re-opens the bypass; too narrow a range over-throttles real visitors. If unsure which network your proxy uses, check a Remark42 request log for the peer address it reports.
### Deprecated parameters
The following list of command-line options is deprecated and might be removed in the next major release after the version they were deprecated. After the Remark42 version update, please check the startup log once for deprecation warning messages to avoid trouble with unrecognized command-line options in the future.
+2
View File
@@ -52,3 +52,5 @@ server {
```
Note: `proxy_pass` points to internal DNS name `remark42` and is expected to run from the same compose. If Nginx runs outside compose, the real IP (or docker's bridge IP) should be used
Because this config sets `X-Real-IP`/`X-Forwarded-For`, set [`--trusted-proxy`](../../configuration/parameters/#trusted-proxies-and-client-ip) to the network Nginx connects from (for a same-compose Nginx that is the Docker network, e.g. `172.16.0.0/12`). Without it Remark42 trusts those headers from any client, which lets them spoof their IP and bypass rate limiting and vote de-duplication.
+2
View File
@@ -82,3 +82,5 @@ services:
reproxy.dest: "/$$1"
reproxy.ping: "/ping"
```
To make per-IP rate limiting and vote de-duplication use the real client IP, set [`--trusted-proxy`](../../configuration/parameters/#trusted-proxies-and-client-ip) to the network Reproxy connects from (the Docker network for a same-compose Reproxy, e.g. `172.16.0.0/12`). Two Reproxy-specific cautions: Reproxy derives `X-Real-IP` from an incoming `X-Forwarded-For`, so a directly-exposed Reproxy still lets a client spoof the IP — front it with something that strips client `X-Forwarded-For`; and drop the `ports: - "8080"` mapping on the `remark42` service once Reproxy is the entry point, otherwise Remark42 is reachable directly and external traffic appears as a trusted Docker peer.