Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ package proxy
type Config struct {
// TrustedSubnets declare IP subnets which are allowed to set ip using X-Real-Ip and X-Forwarded-For
TrustedSubnets []string `mapstructure:"trusted_subnets"`
// TrustedHeaders is the ordered allowlist of headers consulted to resolve the
// real client IP. When empty, the built-in default order is used.
TrustedHeaders []string `mapstructure:"trusted_headers"`
}
6 changes: 5 additions & 1 deletion doc.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// Package proxy provides an HTTP middleware plugin for RoadRunner that resolves
// the real client IP address from proxy headers (X-Forwarded-For, X-Real-Ip,
// True-Client-Ip, CF-Connecting-Ip, Forwarded) when requests arrive through
// True-Client-Ip, Cf-Connecting-Ip, Forwarded) when requests arrive through
// trusted subnets.
//
// The headers consulted are configurable via http.trusted_headers: an ordered
// allowlist where the first non-empty match wins. When it is unset, the headers
// above are used in that default order.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
package proxy
148 changes: 110 additions & 38 deletions plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ import (
)

const (
name string = "proxy_ip_parser"
configKey string = "http.trusted_subnets"
xff string = "X-Forwarded-For"
xrip string = "X-Real-Ip"
tcip string = "True-Client-Ip"
cfip string = "Cf-Connecting-Ip"
forwarded string = "Forwarded"
name string = "proxy_ip_parser"
configKey string = "http.trusted_subnets"
headersKey string = "http.trusted_headers"
xff string = "X-Forwarded-For"
xrip string = "X-Real-Ip"
tcip string = "True-Client-Ip"
cfip string = "Cf-Connecting-Ip"
forwarded string = "Forwarded"
)

var forwardedRegex = regexp.MustCompile(`(?i)(?:for=)([^(;|,| )]+)`)
Expand All @@ -41,10 +42,11 @@ type Configurer interface {
}

type Plugin struct {
cfg *Config
log *slog.Logger
trusted []*net.IPNet
prop propagation.TextMapPropagator
cfg *Config
log *slog.Logger
trusted []*net.IPNet
resolvers []resolver
prop propagation.TextMapPropagator
}

func (p *Plugin) Init(cfg Configurer, l Logger) error {
Expand Down Expand Up @@ -77,6 +79,13 @@ func (p *Plugin) Init(cfg Configurer, l Logger) error {
p.trusted[i] = ipNet
}

if cfg.Has(headersKey) {
if err := cfg.UnmarshalKey(headersKey, &p.cfg.TrustedHeaders); err != nil {
return errors.E(op, err)
}
}
p.resolvers = buildResolvers(p.cfg.TrustedHeaders)

return nil
}

Expand Down Expand Up @@ -129,38 +138,101 @@ func (p *Plugin) Name() string {
return name
}

// get real ip passing multiple proxy
func (p *Plugin) resolveIP(headers http.Header) string {
// new Forwarded header
// https://datatracker.ietf.org/doc/html/rfc7239
if fwd := headers.Get(forwarded); fwd != "" {
if get := forwardedRegex.FindStringSubmatch(fwd); len(get) > 1 {
// IPv6 -> It is important to note that an IPv6 address and any nodename with
// node-port specified MUST be quoted
// we should trim the "
return strings.Trim(get[1], `"`)
// resolver extracts a candidate client IP from a single header value.
type resolver struct {
name string // canonical header name, e.g. "X-Forwarded-For"
parse func(string) string
}

// defaultResolvers returns the built-in resolution chain used when
// http.trusted_headers is not configured. True-Client-Ip and Cf-Connecting-Ip
// (CloudFlare) are checked last, matching their historical priority.
func defaultResolvers() []resolver {
return []resolver{
{forwarded, parseForwarded},
{xff, parseXFF},
{xrip, parseVerbatim},
{tcip, parseVerbatim},
{cfip, parseVerbatim},
}
}

// buildResolvers turns the configured header allowlist into an ordered resolver
// chain. Entries are trimmed and canonicalized, blanks and duplicates dropped.
// An empty allowlist falls back to the default chain.
func buildResolvers(headers []string) []resolver {
resolvers := make([]resolver, 0, len(headers))
seen := make(map[string]struct{}, len(headers))

for i := range headers {
h := strings.TrimSpace(headers[i])
if h == "" {
continue
}

canon := http.CanonicalHeaderKey(h)
if _, ok := seen[canon]; ok {
continue
}
// XFF parse
} else if fwd := headers.Get(xff); fwd != "" {
// take the first address; Cut returns the whole string when no comma is present
before, _, _ := strings.Cut(fwd, ",")
return before
// next -> X-Real-Ip
} else if fwd := headers.Get(xrip); fwd != "" {
return fwd

seen[canon] = struct{}{}
resolvers = append(resolvers, resolver{canon, parserFor(canon)})
}

if len(resolvers) == 0 {
return defaultResolvers()
}

// The logic here is the following:
// CloudFlare headers
// True-Client-IP is a general CF header in which copied information from X-Real-Ip in CF.
// CF-Connecting-IP is an Enterprise feature and we check it last in order.
// This operations are near O(1) because Headers struct are the map type -> type MIMEHeader map[string][]string
if fwd := headers.Get(tcip); fwd != "" {
return fwd
return resolvers
}

// parserFor selects the value parser for a canonical header name. The two
// structured headers keep dedicated parsers; everything else (including custom
// headers) is taken verbatim.
func parserFor(canon string) func(string) string {
switch canon {
case forwarded:
return parseForwarded
case xff:
return parseXFF
default:
return parseVerbatim
}
}

// parseForwarded extracts the "for=" target from an RFC 7239 Forwarded header.
// https://datatracker.ietf.org/doc/html/rfc7239
func parseForwarded(v string) string {
if m := forwardedRegex.FindStringSubmatch(v); len(m) > 1 {
// An IPv6 address (and any node-port) MUST be quoted, so trim the quotes.
return strings.Trim(m[1], `"`)
}

if fwd := headers.Get(cfip); fwd != "" {
return fwd
return ""
}

// parseXFF takes the left-most address from an X-Forwarded-For list.
func parseXFF(v string) string {
// Cut returns the whole string when no comma is present.
before, _, _ := strings.Cut(v, ",")
return before
}

// parseVerbatim returns the header value unchanged (X-Real-Ip, True-Client-Ip,
// Cf-Connecting-Ip and custom headers carry a single address).
func parseVerbatim(v string) string {
return v
}

// resolveIP returns the first non-empty client IP parsed from the configured
// (or default) header chain.
func (p *Plugin) resolveIP(headers http.Header) string {
for i := range p.resolvers {
if raw := headers.Get(p.resolvers[i].name); raw != "" {
if ip := p.resolvers[i].parse(raw); ip != "" {
return ip
Comment thread
rustatian marked this conversation as resolved.
}
}
}

return ""
Expand Down
5 changes: 3 additions & 2 deletions plugin_otel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
rrcontext "github.com/roadrunner-server/context"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
Expand All @@ -22,7 +23,7 @@ func TestMiddlewareSpanEndsBeforeNextHandler(t *testing.T) {
_, ipNet, err := net.ParseCIDR("127.0.0.0/8")
require.NoError(t, err)

p := &Plugin{trusted: []*net.IPNet{ipNet}}
p := &Plugin{trusted: []*net.IPNet{ipNet}, prop: propagation.TraceContext{}}

// "next" handler that creates its own span to mark when downstream starts
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -78,7 +79,7 @@ func TestMiddlewareSpanEndsOnError(t *testing.T) {
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
t.Cleanup(func() { _ = tp.Shutdown(t.Context()) })

p := &Plugin{}
p := &Plugin{prop: propagation.TraceContext{}}

nextCalled := false
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
Expand Down
37 changes: 37 additions & 0 deletions tests/configs/.rr-http-headers.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
version: '3'

rpc:
listen: tcp://127.0.0.1:6001

server:
command: "php php_test_files/http/client.php ip pipes"
relay: "pipes"
relay_timeout: "20s"

http:
address: 127.0.0.1:12411
max_request_size: 1024
middleware: [ "proxy_ip_parser" ]
uploads:
forbid: [ ".php", ".exe", ".bat" ]
trusted_subnets:
[
"10.0.0.0/8",
"127.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10"
]
# only X-Real-Ip is trusted; X-Forwarded-* and the rest are ignored
trusted_headers: [ "X-Real-Ip" ]
pool:
num_workers: 2
max_jobs: 0
allocate_timeout: 60s
destroy_timeout: 60s

logs:
mode: development
level: error
5 changes: 2 additions & 3 deletions tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ require (
github.com/joho/godotenv v1.5.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mholt/acmez v1.2.0 // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
Expand All @@ -43,7 +43,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
Expand Down Expand Up @@ -76,7 +76,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/mod v0.36.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYq
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30=
Expand All @@ -70,8 +70,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
Expand Down
11 changes: 11 additions & 0 deletions tests/php_test_files/http/ip.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

function handleRequest(ServerRequestInterface $req, ResponseInterface $resp): ResponseInterface
{
$resp->getBody()->write($req->getServerParams()['REMOTE_ADDR']);

return $resp;
}
Loading
Loading