Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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"`
}
8 changes: 6 additions & 2 deletions 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
// the real client IP address from proxy headers (Forwarded, X-Forwarded-For,
// X-Real-Ip, True-Client-Ip, Cf-Connecting-Ip) 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. The parser
// for each header comes from parserFor, the single source of that mapping.
func defaultResolvers() []resolver {
chain := []string{forwarded, xff, xrip, tcip, cfip}
resolvers := make([]resolver, len(chain))
for i, h := range chain {
resolvers[i] = resolver{h, parserFor(h)}
}
return resolvers
}

// 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 _, hdr := range headers {
h := strings.TrimSpace(hdr)
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 _, r := range p.resolvers {
if raw := headers.Get(r.name); raw != "" {
if ip := r.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