mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-22 16:31:28 +02:00
[relay] only trust X-Real-Ip headers from configured trusted proxies (#6833)
The WS listener unconditionally trusted X-Real-Ip/X-Real-Port headers, letting any client forge the source address the relay logs. Gate header trust behind a trusted-proxy allowlist; ignore the headers unless the immediate peer matches a configured prefix. Defaults to never trusting the headers when the allowlist is empty. ## Describe your changes ## Issue ticket number and link ## Stack <!-- branch-stack --> ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6833"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img alt="View with Codesmith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787141580&installation_id=146802194&pr_number=6833&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6833&signature=9cf182cc7be248e457dfdb56e8a047401276d8cc567ed8ae715ec1cc809f1b6a"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with Codesmith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a> <sup>Need help on this PR? Tag <code>/codesmith</code> with what you need. Autofix is disabled.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `--trusted-proxies` to configure a comma-separated allowlist of trusted upstream proxy IPs/CIDRs. * **Behavior Changes** * Relay WebSocket now uses `X-Real-Ip` / `X-Real-Port` only when the immediate peer is from the configured trusted set; otherwise it falls back to the direct remote address. * Proxy client IP resolution is now consistent and honors `X-Forwarded-For` only through trusted hops. * **Operational** * Invalid `--trusted-proxies` values fail fast on startup. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/embed"
|
"github.com/netbirdio/netbird/client/embed"
|
||||||
"github.com/netbirdio/netbird/proxy"
|
"github.com/netbirdio/netbird/proxy"
|
||||||
nbacme "github.com/netbirdio/netbird/proxy/internal/acme"
|
nbacme "github.com/netbirdio/netbird/proxy/internal/acme"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err)
|
return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies)
|
parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid --trusted-proxies: %w", err)
|
return fmt.Errorf("invalid --trusted-proxies: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/proxy/auth"
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -66,7 +67,7 @@ type denyBucket struct {
|
|||||||
type Logger struct {
|
type Logger struct {
|
||||||
client gRPCClient
|
client gRPCClient
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
trustedProxies []netip.Prefix
|
trustedProxies *trustedproxy.List
|
||||||
|
|
||||||
usageMux sync.Mutex
|
usageMux sync.Mutex
|
||||||
domainUsage map[string]*domainUsage
|
domainUsage map[string]*domainUsage
|
||||||
@@ -82,7 +83,7 @@ type Logger struct {
|
|||||||
// NewLogger creates a new access log Logger. The trustedProxies parameter
|
// NewLogger creates a new access log Logger. The trustedProxies parameter
|
||||||
// configures which upstream proxy IP ranges are trusted for extracting
|
// configures which upstream proxy IP ranges are trusted for extracting
|
||||||
// the real client IP from X-Forwarded-For headers.
|
// the real client IP from X-Forwarded-For headers.
|
||||||
func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Prefix) *Logger {
|
func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = log.StandardLogger()
|
logger = log.StandardLogger()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// extractSourceIP resolves the real client IP from the request using trusted
|
// extractSourceIP resolves the real client IP from the request using trusted
|
||||||
// proxy configuration. When trustedProxies is non-empty and the direct
|
// proxy configuration. When trustedProxies is non-empty and the direct
|
||||||
// connection is from a trusted source, it walks X-Forwarded-For right-to-left
|
// connection is from a trusted source, it walks X-Forwarded-For right-to-left
|
||||||
// skipping trusted IPs. Otherwise it returns RemoteAddr directly.
|
// skipping trusted IPs. Otherwise it returns RemoteAddr directly.
|
||||||
func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr {
|
func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr {
|
||||||
return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies)
|
return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/proxy/web"
|
"github.com/netbirdio/netbird/proxy/web"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReverseProxy struct {
|
type ReverseProxy struct {
|
||||||
@@ -29,10 +30,10 @@ type ReverseProxy struct {
|
|||||||
// forwardedProto overrides the X-Forwarded-Proto header value.
|
// forwardedProto overrides the X-Forwarded-Proto header value.
|
||||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||||
forwardedProto string
|
forwardedProto string
|
||||||
// trustedProxies is a list of IP prefixes for trusted upstream proxies.
|
// trustedProxies is the set of trusted upstream proxies. When the direct
|
||||||
// When the direct connection comes from a trusted proxy, forwarding
|
// connection comes from a trusted proxy, forwarding headers are preserved
|
||||||
// headers are preserved and appended to instead of being stripped.
|
// and appended to instead of being stripped.
|
||||||
trustedProxies []netip.Prefix
|
trustedProxies *trustedproxy.List
|
||||||
mappingsMux sync.RWMutex
|
mappingsMux sync.RWMutex
|
||||||
mappings map[string]Mapping
|
mappings map[string]Mapping
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
@@ -63,7 +64,7 @@ func WithMiddlewareManager(m *middleware.Manager) Option {
|
|||||||
// between requested URLs and targets.
|
// between requested URLs and targets.
|
||||||
// The internal mappings can be modified using the AddMapping
|
// The internal mappings can be modified using the AddMapping
|
||||||
// and RemoveMapping functions.
|
// and RemoveMapping functions.
|
||||||
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy {
|
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = log.StandardLogger()
|
logger = log.StandardLogger()
|
||||||
}
|
}
|
||||||
@@ -527,7 +528,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
|
|||||||
if !types.IsOverlayOrigin(r.Context()) {
|
if !types.IsOverlayOrigin(r.Context()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
srcIP := extractHostIP(r.RemoteAddr)
|
srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr)
|
||||||
if !srcIP.IsValid() {
|
if !srcIP.IsValid() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -578,9 +579,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
|
|||||||
|
|
||||||
stampNetBirdIdentity(r)
|
stampNetBirdIdentity(r)
|
||||||
|
|
||||||
clientIP := extractHostIP(r.In.RemoteAddr)
|
clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr)
|
||||||
|
|
||||||
if isTrustedAddr(clientIP, p.trustedProxies) {
|
if p.trustedProxies.Contains(clientIP) {
|
||||||
p.setTrustedForwardingHeaders(r, clientIP)
|
p.setTrustedForwardingHeaders(r, clientIP)
|
||||||
} else {
|
} else {
|
||||||
p.setUntrustedForwardingHeaders(r, clientIP)
|
p.setUntrustedForwardingHeaders(r, clientIP)
|
||||||
@@ -664,7 +665,7 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli
|
|||||||
if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" {
|
if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" {
|
||||||
r.Out.Header.Set("X-Real-IP", realIP)
|
r.Out.Header.Set("X-Real-IP", realIP)
|
||||||
} else {
|
} else {
|
||||||
resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies)
|
resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"))
|
||||||
r.Out.Header.Set("X-Real-IP", resolved.String())
|
r.Out.Header.Set("X-Real-IP", resolved.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/proxy/web"
|
"github.com/netbirdio/netbird/proxy/web"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRewriteFunc_HostRewriting(t *testing.T) {
|
func TestRewriteFunc_HostRewriting(t *testing.T) {
|
||||||
@@ -302,7 +303,7 @@ func TestExtractHostIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr))
|
assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +331,7 @@ func TestExtractForwardedPort(t *testing.T) {
|
|||||||
|
|
||||||
func TestRewriteFunc_TrustedProxy(t *testing.T) {
|
func TestRewriteFunc_TrustedProxy(t *testing.T) {
|
||||||
target, _ := url.Parse("http://backend.internal:8080")
|
target, _ := url.Parse("http://backend.internal:8080")
|
||||||
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
trusted := trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")})
|
||||||
|
|
||||||
t.Run("appends to X-Forwarded-For", func(t *testing.T) {
|
t.Run("appends to X-Forwarded-For", func(t *testing.T) {
|
||||||
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes.
|
|
||||||
func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool {
|
|
||||||
addr, err := netip.ParseAddr(ipStr)
|
|
||||||
if err != nil || len(trusted) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return isTrustedAddr(addr.Unmap(), trusted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list.
|
|
||||||
// It walks the XFF chain right-to-left, skipping IPs that match trusted prefixes.
|
|
||||||
// The first untrusted IP is the real client.
|
|
||||||
//
|
|
||||||
// If the trusted list is empty or remoteAddr is not trusted, it returns the
|
|
||||||
// remoteAddr IP directly (ignoring any forwarding headers).
|
|
||||||
func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) netip.Addr {
|
|
||||||
remoteIP := extractHostIP(remoteAddr)
|
|
||||||
|
|
||||||
if len(trusted) == 0 || !isTrustedAddr(remoteIP, trusted) {
|
|
||||||
return remoteIP
|
|
||||||
}
|
|
||||||
|
|
||||||
if xff == "" {
|
|
||||||
return remoteIP
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(xff, ",")
|
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
|
||||||
ip := strings.TrimSpace(parts[i])
|
|
||||||
if ip == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(ip)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr = addr.Unmap()
|
|
||||||
if !isTrustedAddr(addr, trusted) {
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All IPs in XFF are trusted; return the leftmost as best guess.
|
|
||||||
if first := strings.TrimSpace(parts[0]); first != "" {
|
|
||||||
if addr, err := netip.ParseAddr(first); err == nil {
|
|
||||||
return addr.Unmap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return remoteIP
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractHostIP parses the IP from a host:port string and returns it unmapped.
|
|
||||||
func extractHostIP(hostPort string) netip.Addr {
|
|
||||||
if ap, err := netip.ParseAddrPort(hostPort); err == nil {
|
|
||||||
return ap.Addr().Unmap()
|
|
||||||
}
|
|
||||||
if addr, err := netip.ParseAddr(hostPort); err == nil {
|
|
||||||
return addr.Unmap()
|
|
||||||
}
|
|
||||||
return netip.Addr{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTrustedAddr checks if the given address falls within any of the trusted prefixes.
|
|
||||||
func isTrustedAddr(addr netip.Addr, trusted []netip.Prefix) bool {
|
|
||||||
if !addr.IsValid() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, prefix := range trusted {
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsTrustedProxy(t *testing.T) {
|
|
||||||
trusted := []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.0.0.0/8"),
|
|
||||||
netip.MustParsePrefix("192.168.1.0/24"),
|
|
||||||
netip.MustParsePrefix("fd00::/8"),
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ip string
|
|
||||||
trusted []netip.Prefix
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"empty trusted list", "10.0.0.1", nil, false},
|
|
||||||
{"IP within /8 prefix", "10.1.2.3", trusted, true},
|
|
||||||
{"IP within /24 prefix", "192.168.1.100", trusted, true},
|
|
||||||
{"IP outside all prefixes", "203.0.113.50", trusted, false},
|
|
||||||
{"boundary IP just outside prefix", "192.168.2.1", trusted, false},
|
|
||||||
{"unparsable IP", "not-an-ip", trusted, false},
|
|
||||||
{"IPv6 in trusted range", "fd00::1", trusted, true},
|
|
||||||
{"IPv6 outside range", "2001:db8::1", trusted, false},
|
|
||||||
{"empty string", "", trusted, false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.want, IsTrustedProxy(tt.ip, tt.trusted))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveClientIP(t *testing.T) {
|
|
||||||
trusted := []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.0.0.0/8"),
|
|
||||||
netip.MustParsePrefix("172.16.0.0/12"),
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
remoteAddr string
|
|
||||||
xff string
|
|
||||||
trusted []netip.Prefix
|
|
||||||
want netip.Addr
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty trusted list returns RemoteAddr",
|
|
||||||
remoteAddr: "203.0.113.50:9999",
|
|
||||||
xff: "1.2.3.4",
|
|
||||||
trusted: nil,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "untrusted RemoteAddr ignores XFF",
|
|
||||||
remoteAddr: "203.0.113.50:9999",
|
|
||||||
xff: "1.2.3.4, 10.0.0.1",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trusted RemoteAddr with single client in XFF",
|
|
||||||
remoteAddr: "10.0.0.1:5000",
|
|
||||||
xff: "203.0.113.50",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
|
||||||
remoteAddr: "10.0.0.1:5000",
|
|
||||||
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
|
||||||
remoteAddr: "10.0.0.1:5000",
|
|
||||||
xff: "",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("10.0.0.1"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all XFF IPs trusted returns leftmost",
|
|
||||||
remoteAddr: "10.0.0.1:5000",
|
|
||||||
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("10.0.0.2"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "XFF with whitespace",
|
|
||||||
remoteAddr: "10.0.0.1:5000",
|
|
||||||
xff: " 203.0.113.50 , 10.0.0.2 ",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "XFF with empty segments",
|
|
||||||
remoteAddr: "10.0.0.1:5000",
|
|
||||||
xff: "203.0.113.50,,10.0.0.2",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multi-hop with mixed trust",
|
|
||||||
remoteAddr: "10.0.0.1:5000",
|
|
||||||
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "RemoteAddr without port",
|
|
||||||
remoteAddr: "10.0.0.1",
|
|
||||||
xff: "203.0.113.50",
|
|
||||||
trusted: trusted,
|
|
||||||
want: netip.MustParseAddr("203.0.113.50"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.want, ResolveClientIP(tt.remoteAddr, tt.xff, tt.trusted))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,13 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/netip"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/embed"
|
"github.com/netbirdio/netbird/client/embed"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/acme"
|
"github.com/netbirdio/netbird/proxy/internal/acme"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config bundles every knob the proxy reads at construction time. It mirrors
|
// Config bundles every knob the proxy reads at construction time. It mirrors
|
||||||
@@ -83,9 +83,9 @@ type Config struct {
|
|||||||
// ForwardedProto overrides the X-Forwarded-Proto value sent to
|
// ForwardedProto overrides the X-Forwarded-Proto value sent to
|
||||||
// backends. Valid values: "auto", "http", "https".
|
// backends. Valid values: "auto", "http", "https".
|
||||||
ForwardedProto string
|
ForwardedProto string
|
||||||
// TrustedProxies is a list of IP prefixes for trusted upstream
|
// TrustedProxies is the set of trusted upstream proxies that may set
|
||||||
// proxies that may set forwarding headers.
|
// forwarding headers.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies *trustedproxy.List
|
||||||
// WireguardPort is the UDP port for the embedded NetBird tunnel.
|
// WireguardPort is the UDP port for the embedded NetBird tunnel.
|
||||||
// Zero asks the OS for a random port.
|
// Zero asks the OS for a random port.
|
||||||
WireguardPort uint16
|
WireguardPort uint16
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
Logger: log.StandardLogger(),
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
|
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}),
|
||||||
ProxyProtocol: true,
|
ProxyProtocol: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
|
|||||||
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
Logger: log.StandardLogger(),
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := proxyproto.ConnPolicyOptions{
|
opts := proxyproto.ConnPolicyOptions{
|
||||||
@@ -80,7 +82,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
|
|||||||
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
Logger: log.StandardLogger(),
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := proxyproto.ConnPolicyOptions{
|
opts := proxyproto.ConnPolicyOptions{
|
||||||
@@ -94,7 +96,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
|
|||||||
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
|
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
Logger: log.StandardLogger(),
|
Logger: log.StandardLogger(),
|
||||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := proxyproto.ConnPolicyOptions{
|
opts := proxyproto.ConnPolicyOptions{
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/proxy/web"
|
"github.com/netbirdio/netbird/proxy/web"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
"github.com/netbirdio/netbird/util/embeddedroots"
|
"github.com/netbirdio/netbird/util/embeddedroots"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,19 +80,19 @@ type portRouter struct {
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
mgmtClient proto.ProxyServiceClient
|
mgmtClient proto.ProxyServiceClient
|
||||||
proxy *proxy.ReverseProxy
|
proxy *proxy.ReverseProxy
|
||||||
netbird *roundtrip.NetBird
|
netbird *roundtrip.NetBird
|
||||||
acme *acme.Manager
|
acme *acme.Manager
|
||||||
staticCertWatcher *certwatch.Watcher
|
staticCertWatcher *certwatch.Watcher
|
||||||
auth *auth.Middleware
|
auth *auth.Middleware
|
||||||
http *http.Server
|
http *http.Server
|
||||||
https *http.Server
|
https *http.Server
|
||||||
debug *http.Server
|
debug *http.Server
|
||||||
healthServer *health.Server
|
healthServer *health.Server
|
||||||
healthChecker *health.Checker
|
healthChecker *health.Checker
|
||||||
meter *proxymetrics.Metrics
|
meter *proxymetrics.Metrics
|
||||||
accessLog *accesslog.Logger
|
accessLog *accesslog.Logger
|
||||||
// middlewareManager drives per-target middleware dispatch. Always
|
// middlewareManager drives per-target middleware dispatch. Always
|
||||||
// constructed during boot; an empty registry produces empty chains and
|
// constructed during boot; an empty registry produces empty chains and
|
||||||
// the reverse-proxy stays on the no-capture fast path.
|
// the reverse-proxy stays on the no-capture fast path.
|
||||||
@@ -99,16 +100,16 @@ type Server struct {
|
|||||||
// middlewareRegistry is the source of registered middleware factories.
|
// middlewareRegistry is the source of registered middleware factories.
|
||||||
// Concrete middlewares register themselves through init().
|
// Concrete middlewares register themselves through init().
|
||||||
middlewareRegistry *middleware.Registry
|
middlewareRegistry *middleware.Registry
|
||||||
mainRouter *nbtcp.Router
|
mainRouter *nbtcp.Router
|
||||||
mainPort uint16
|
mainPort uint16
|
||||||
udpMu sync.Mutex
|
udpMu sync.Mutex
|
||||||
udpRelays map[types.ServiceID]*udprelay.Relay
|
udpRelays map[types.ServiceID]*udprelay.Relay
|
||||||
udpRelayWg sync.WaitGroup
|
udpRelayWg sync.WaitGroup
|
||||||
portMu sync.RWMutex
|
portMu sync.RWMutex
|
||||||
portRouters map[uint16]*portRouter
|
portRouters map[uint16]*portRouter
|
||||||
svcPorts map[types.ServiceID][]uint16
|
svcPorts map[types.ServiceID][]uint16
|
||||||
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
lastMappings map[types.ServiceID]*proto.ProxyMapping
|
||||||
portRouterWg sync.WaitGroup
|
portRouterWg sync.WaitGroup
|
||||||
|
|
||||||
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
|
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
|
||||||
// so they can be closed during graceful shutdown, since http.Server.Shutdown
|
// so they can be closed during graceful shutdown, since http.Server.Shutdown
|
||||||
@@ -192,10 +193,10 @@ type Server struct {
|
|||||||
// ForwardedProto overrides the X-Forwarded-Proto value sent to backends.
|
// ForwardedProto overrides the X-Forwarded-Proto value sent to backends.
|
||||||
// Valid values: "auto" (detect from TLS), "http", "https".
|
// Valid values: "auto" (detect from TLS), "http", "https".
|
||||||
ForwardedProto string
|
ForwardedProto string
|
||||||
// TrustedProxies is a list of IP prefixes for trusted upstream proxies.
|
// TrustedProxies is the set of trusted upstream proxies. When set,
|
||||||
// When set, forwarding headers from these sources are preserved and
|
// forwarding headers from these sources are preserved and appended to
|
||||||
// appended to instead of being stripped.
|
// instead of being stripped.
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies *trustedproxy.List
|
||||||
// WireguardPort is the port for the NetBird tunnel interface. Use 0
|
// WireguardPort is the port for the NetBird tunnel interface. Use 0
|
||||||
// for a random OS-assigned port. A fixed port only works with
|
// for a random OS-assigned port. A fixed port only works with
|
||||||
// single-account deployments; multiple accounts will fail to bind
|
// single-account deployments; multiple accounts will fail to bind
|
||||||
@@ -718,7 +719,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener {
|
|||||||
Listener: ln,
|
Listener: ln,
|
||||||
ReadHeaderTimeout: proxyProtoHeaderTimeout,
|
ReadHeaderTimeout: proxyProtoHeaderTimeout,
|
||||||
}
|
}
|
||||||
if len(s.TrustedProxies) > 0 {
|
if !s.TrustedProxies.Empty() {
|
||||||
ppListener.ConnPolicy = s.proxyProtocolPolicy
|
ppListener.ConnPolicy = s.proxyProtocolPolicy
|
||||||
} else {
|
} else {
|
||||||
s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers")
|
s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers")
|
||||||
@@ -742,10 +743,8 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr
|
|||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
|
|
||||||
// called per accept
|
// called per accept
|
||||||
for _, prefix := range s.TrustedProxies {
|
if s.TrustedProxies.Contains(addr) {
|
||||||
if prefix.Contains(addr) {
|
return proxyproto.REQUIRE, nil
|
||||||
return proxyproto.REQUIRE, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return proxyproto.IGNORE, nil
|
return proxyproto.IGNORE, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseTrustedProxies parses a comma-separated list of CIDR prefixes or bare IPs
|
|
||||||
// into a slice of netip.Prefix values suitable for trusted proxy configuration.
|
|
||||||
// Bare IPs are converted to single-host prefixes (/32 or /128).
|
|
||||||
func ParseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(raw, ",")
|
|
||||||
prefixes := make([]netip.Prefix, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix, err := netip.ParsePrefix(part)
|
|
||||||
if err == nil {
|
|
||||||
prefixes = append(prefixes, prefix)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
addr, addrErr := netip.ParseAddr(part)
|
|
||||||
if addrErr != nil {
|
|
||||||
return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
bits := 32
|
|
||||||
if addr.Is6() {
|
|
||||||
bits = 128
|
|
||||||
}
|
|
||||||
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
|
|
||||||
}
|
|
||||||
return prefixes, nil
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseTrustedProxies(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
raw string
|
|
||||||
want []netip.Prefix
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty string returns nil",
|
|
||||||
raw: "",
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single CIDR",
|
|
||||||
raw: "10.0.0.0/8",
|
|
||||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single bare IPv4",
|
|
||||||
raw: "1.2.3.4",
|
|
||||||
want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single bare IPv6",
|
|
||||||
raw: "::1",
|
|
||||||
want: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "comma-separated CIDRs",
|
|
||||||
raw: "10.0.0.0/8, 192.168.1.0/24",
|
|
||||||
want: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.0.0.0/8"),
|
|
||||||
netip.MustParsePrefix("192.168.1.0/24"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed CIDRs and bare IPs",
|
|
||||||
raw: "10.0.0.0/8, 1.2.3.4, fd00::/8",
|
|
||||||
want: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.0.0.0/8"),
|
|
||||||
netip.MustParsePrefix("1.2.3.4/32"),
|
|
||||||
netip.MustParsePrefix("fd00::/8"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace around entries",
|
|
||||||
raw: " 10.0.0.0/8 , 192.168.0.0/16 ",
|
|
||||||
want: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.0.0.0/8"),
|
|
||||||
netip.MustParsePrefix("192.168.0.0/16"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trailing comma produces no extra entry",
|
|
||||||
raw: "10.0.0.0/8,",
|
|
||||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid entry",
|
|
||||||
raw: "not-an-ip",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "partially invalid",
|
|
||||||
raw: "10.0.0.0/8, garbage",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := ParseTrustedProxies(tt.raw)
|
|
||||||
if tt.wantErr {
|
|
||||||
require.Error(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.want, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/shared/metrics"
|
"github.com/netbirdio/netbird/shared/metrics"
|
||||||
"github.com/netbirdio/netbird/shared/relay/auth"
|
"github.com/netbirdio/netbird/shared/relay/auth"
|
||||||
"github.com/netbirdio/netbird/stun"
|
"github.com/netbirdio/netbird/stun"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,9 @@ type Config struct {
|
|||||||
LogLevel string
|
LogLevel string
|
||||||
LogFile string
|
LogFile string
|
||||||
HealthcheckListenAddress string
|
HealthcheckListenAddress string
|
||||||
|
// TrustedProxies is a comma-separated list of upstream proxy CIDRs/IPs whose
|
||||||
|
// X-Real-Ip/X-Real-Port headers are trusted. Empty means never trust these headers.
|
||||||
|
TrustedProxies string
|
||||||
// STUN server configuration
|
// STUN server configuration
|
||||||
EnableSTUN bool
|
EnableSTUN bool
|
||||||
STUNPorts []int
|
STUNPorts []int
|
||||||
@@ -116,6 +120,7 @@ func init() {
|
|||||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
|
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogLevel, "log-level", "info", "log level")
|
||||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file")
|
rootCmd.PersistentFlags().StringVar(&cobraConfig.LogFile, "log-file", "console", "log file")
|
||||||
rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server")
|
rootCmd.PersistentFlags().StringVarP(&cobraConfig.HealthcheckListenAddress, "health-listen-address", "H", ":9000", "listen address of healthcheck server")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&cobraConfig.TrustedProxies, "trusted-proxies", "", "comma-separated list of upstream proxy CIDRs or IPs whose X-Real-Ip/X-Real-Port headers are trusted; leave empty to always use the direct connection address")
|
||||||
rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server")
|
rootCmd.PersistentFlags().BoolVar(&cobraConfig.EnableSTUN, "enable-stun", false, "enable embedded STUN server")
|
||||||
rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)")
|
rootCmd.PersistentFlags().IntSliceVar(&cobraConfig.STUNPorts, "stun-ports", []int{3478}, "ports for the embedded STUN server (can be specified multiple times or comma-separated)")
|
||||||
rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)")
|
rootCmd.PersistentFlags().StringVar(&cobraConfig.STUNLogLevel, "stun-log-level", "info", "log level for STUN server (panic, fatal, error, warn, info, debug, trace)")
|
||||||
@@ -155,8 +160,15 @@ func execute(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("setup metrics: %v", err)
|
return fmt.Errorf("setup metrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trustedProxies, err := trustedproxy.Parse(cobraConfig.TrustedProxies)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("failed to parse trusted proxies: %s", err)
|
||||||
|
return fmt.Errorf("failed to parse trusted proxies: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
srvListenerCfg := server.ListenerConfig{
|
srvListenerCfg := server.ListenerConfig{
|
||||||
Address: cobraConfig.ListenAddress,
|
Address: cobraConfig.ListenAddress,
|
||||||
|
TrustedProxies: trustedProxies,
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig)
|
tlsConfig, tlsSupport, err := handleTLSConfig(cobraConfig)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/relay/protocol"
|
"github.com/netbirdio/netbird/relay/protocol"
|
||||||
relaylistener "github.com/netbirdio/netbird/relay/server/listener"
|
relaylistener "github.com/netbirdio/netbird/relay/server/listener"
|
||||||
"github.com/netbirdio/netbird/shared/relay"
|
"github.com/netbirdio/netbird/shared/relay"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,6 +28,9 @@ type Listener struct {
|
|||||||
Address string
|
Address string
|
||||||
// TLSConfig is the TLS configuration for the server.
|
// TLSConfig is the TLS configuration for the server.
|
||||||
TLSConfig *tls.Config
|
TLSConfig *tls.Config
|
||||||
|
// TrustedProxies is the set of upstream proxies whose X-Real-Ip/X-Real-Port
|
||||||
|
// headers are trusted. Headers from any other immediate peer are ignored.
|
||||||
|
TrustedProxies *trustedproxy.List
|
||||||
|
|
||||||
server *http.Server
|
server *http.Server
|
||||||
acceptFn func(conn relaylistener.Conn)
|
acceptFn func(conn relaylistener.Conn)
|
||||||
@@ -75,7 +79,7 @@ func (l *Listener) Shutdown(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
||||||
connRemoteAddr := remoteAddr(r)
|
connRemoteAddr := remoteAddr(r, l.TrustedProxies)
|
||||||
|
|
||||||
acceptOptions := &websocket.AcceptOptions{
|
acceptOptions := &websocket.AcceptOptions{
|
||||||
OriginPatterns: []string{"*"},
|
OriginPatterns: []string{"*"},
|
||||||
@@ -102,9 +106,17 @@ func (l *Listener) onAccept(w http.ResponseWriter, r *http.Request) {
|
|||||||
l.acceptFn(conn)
|
l.acceptFn(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func remoteAddr(r *http.Request) string {
|
func remoteAddr(r *http.Request, trustedProxies *trustedproxy.List) string {
|
||||||
if r.Header.Get("X-Real-Ip") == "" || r.Header.Get("X-Real-Port") == "" {
|
realIP := r.Header.Get("X-Real-Ip")
|
||||||
|
realPort := r.Header.Get("X-Real-Port")
|
||||||
|
if realIP == "" || realPort == "" {
|
||||||
return r.RemoteAddr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
return net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port"))
|
|
||||||
|
if !trustedProxies.IsTrusted(r.RemoteAddr) {
|
||||||
|
log.Debugf("ignoring X-Real-Ip header from untrusted peer %s", r.RemoteAddr)
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return net.JoinHostPort(realIP, realPort)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import (
|
|||||||
"github.com/netbirdio/netbird/relay/server/listener/quic"
|
"github.com/netbirdio/netbird/relay/server/listener/quic"
|
||||||
"github.com/netbirdio/netbird/relay/server/listener/ws"
|
"github.com/netbirdio/netbird/relay/server/listener/ws"
|
||||||
quictls "github.com/netbirdio/netbird/shared/relay/tls"
|
quictls "github.com/netbirdio/netbird/shared/relay/tls"
|
||||||
|
"github.com/netbirdio/netbird/trustedproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListenerConfig is the configuration for the listener.
|
// ListenerConfig is the configuration for the listener.
|
||||||
// Address: the address to bind the listener to. It could be an address behind a reverse proxy.
|
// Address: the address to bind the listener to. It could be an address behind a reverse proxy.
|
||||||
// TLSConfig: the TLS configuration for the listener.
|
// TLSConfig: the TLS configuration for the listener.
|
||||||
|
// TrustedProxies: upstream proxy prefixes whose forwarding headers (X-Real-Ip/X-Real-Port) are trusted.
|
||||||
type ListenerConfig struct {
|
type ListenerConfig struct {
|
||||||
Address string
|
Address string
|
||||||
TLSConfig *tls.Config
|
TLSConfig *tls.Config
|
||||||
|
TrustedProxies *trustedproxy.List
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the main entry point for the relay server.
|
// Server is the main entry point for the relay server.
|
||||||
@@ -62,8 +65,9 @@ func NewServer(config Config) (*Server, error) {
|
|||||||
// Listen starts the relay server.
|
// Listen starts the relay server.
|
||||||
func (r *Server) Listen(cfg ListenerConfig) error {
|
func (r *Server) Listen(cfg ListenerConfig) error {
|
||||||
wSListener := &ws.Listener{
|
wSListener := &ws.Listener{
|
||||||
Address: cfg.Address,
|
Address: cfg.Address,
|
||||||
TLSConfig: cfg.TLSConfig,
|
TLSConfig: cfg.TLSConfig,
|
||||||
|
TrustedProxies: cfg.TrustedProxies,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.listenerMux.Lock()
|
r.listenerMux.Lock()
|
||||||
|
|||||||
132
trustedproxy/trustedproxy.go
Normal file
132
trustedproxy/trustedproxy.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package trustedproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List holds a parsed set of trusted upstream proxy prefixes and answers trust
|
||||||
|
// questions against it. The zero value (and a nil *List) is a valid, empty list
|
||||||
|
// that never trusts any address, so callers can use it without a nil check.
|
||||||
|
type List struct {
|
||||||
|
prefixes []netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parses a comma-separated list of CIDR prefixes or bare IPs into a List.
|
||||||
|
// Bare IPs are converted to single-host prefixes (/32 or /128). An empty input
|
||||||
|
// yields an empty List that trusts nothing.
|
||||||
|
func Parse(raw string) (*List, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return &List{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, err := netip.ParsePrefix(part)
|
||||||
|
if err == nil {
|
||||||
|
prefixes = append(prefixes, prefix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, addrErr := netip.ParseAddr(part)
|
||||||
|
if addrErr != nil {
|
||||||
|
return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
bits := 32
|
||||||
|
if addr.Is6() {
|
||||||
|
bits = 128
|
||||||
|
}
|
||||||
|
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
|
||||||
|
}
|
||||||
|
return &List{prefixes: prefixes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromPrefixes wraps an already-parsed set of prefixes in a List.
|
||||||
|
func FromPrefixes(prefixes []netip.Prefix) *List {
|
||||||
|
return &List{prefixes: prefixes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty reports whether the list contains no prefixes.
|
||||||
|
func (l *List) Empty() bool {
|
||||||
|
return l == nil || len(l.prefixes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTrusted reports whether the given host:port or bare IP falls within the list.
|
||||||
|
func (l *List) IsTrusted(remoteAddr string) bool {
|
||||||
|
if l.Empty() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return l.Contains(ExtractHostIP(remoteAddr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether the given address falls within any trusted prefix.
|
||||||
|
func (l *List) Contains(addr netip.Addr) bool {
|
||||||
|
if l.Empty() || !addr.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, prefix := range l.prefixes {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the
|
||||||
|
// list. It walks the XFF chain right-to-left, skipping IPs that match trusted
|
||||||
|
// prefixes; the first untrusted IP is the real client. If the list is empty or
|
||||||
|
// remoteAddr is not trusted, it returns the remoteAddr IP directly, ignoring any
|
||||||
|
// forwarding headers.
|
||||||
|
func (l *List) ResolveClientIP(remoteAddr, xff string) netip.Addr {
|
||||||
|
remoteIP := ExtractHostIP(remoteAddr)
|
||||||
|
|
||||||
|
if l.Empty() || !l.Contains(remoteIP) {
|
||||||
|
return remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
if xff == "" {
|
||||||
|
return remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
ip := strings.TrimSpace(parts[i])
|
||||||
|
if ip == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(ip)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
if !l.Contains(addr) {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if first := strings.TrimSpace(parts[0]); first != "" {
|
||||||
|
if addr, err := netip.ParseAddr(first); err == nil {
|
||||||
|
return addr.Unmap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractHostIP parses the IP from a host:port string and returns it unmapped.
|
||||||
|
func ExtractHostIP(hostPort string) netip.Addr {
|
||||||
|
if ap, err := netip.ParseAddrPort(hostPort); err == nil {
|
||||||
|
return ap.Addr().Unmap()
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(hostPort); err == nil {
|
||||||
|
return addr.Unmap()
|
||||||
|
}
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
216
trustedproxy/trustedproxy_test.go
Normal file
216
trustedproxy/trustedproxy_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package trustedproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want []netip.Prefix
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty string returns empty list",
|
||||||
|
raw: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single CIDR",
|
||||||
|
raw: "10.0.0.0/8",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single bare IPv4",
|
||||||
|
raw: "1.2.3.4",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single bare IPv6",
|
||||||
|
raw: "::1",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comma-separated CIDRs",
|
||||||
|
raw: "10.0.0.0/8, 192.168.1.0/24",
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("192.168.1.0/24"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed CIDRs and bare IPs",
|
||||||
|
raw: "10.0.0.0/8, 1.2.3.4, fd00::/8",
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("1.2.3.4/32"),
|
||||||
|
netip.MustParsePrefix("fd00::/8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace around entries",
|
||||||
|
raw: " 10.0.0.0/8 , 192.168.0.0/16 ",
|
||||||
|
want: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing comma produces no extra entry",
|
||||||
|
raw: "10.0.0.0/8,",
|
||||||
|
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid entry",
|
||||||
|
raw: "not-an-ip",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partially invalid",
|
||||||
|
raw: "10.0.0.0/8, garbage",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := Parse(tt.raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got.prefixes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsTrusted(t *testing.T) {
|
||||||
|
list, err := Parse("10.0.0.0/8, 192.168.1.0/24, fd00::/8")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
addr string
|
||||||
|
list *List
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil list", "10.0.0.1", nil, false},
|
||||||
|
{"empty list", "10.0.0.1", &List{}, false},
|
||||||
|
{"IP within /8 prefix", "10.1.2.3", list, true},
|
||||||
|
{"IP within /24 prefix", "192.168.1.100", list, true},
|
||||||
|
{"IP outside all prefixes", "203.0.113.50", list, false},
|
||||||
|
{"boundary IP just outside prefix", "192.168.2.1", list, false},
|
||||||
|
{"unparsable IP", "not-an-ip", list, false},
|
||||||
|
{"IPv6 in trusted range", "fd00::1", list, true},
|
||||||
|
{"IPv6 outside range", "2001:db8::1", list, false},
|
||||||
|
{"empty string", "", list, false},
|
||||||
|
{"host:port within prefix", "10.1.2.3:9999", list, true},
|
||||||
|
{"host:port outside prefix", "203.0.113.50:9999", list, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, tt.list.IsTrusted(tt.addr))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListResolveClientIP(t *testing.T) {
|
||||||
|
trusted, err := Parse("10.0.0.0/8, 172.16.0.0/12")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
xff string
|
||||||
|
list *List
|
||||||
|
want netip.Addr
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty list returns RemoteAddr",
|
||||||
|
remoteAddr: "203.0.113.50:9999",
|
||||||
|
xff: "1.2.3.4",
|
||||||
|
list: &List{},
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil list returns RemoteAddr",
|
||||||
|
remoteAddr: "203.0.113.50:9999",
|
||||||
|
xff: "1.2.3.4",
|
||||||
|
list: nil,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "untrusted RemoteAddr ignores XFF",
|
||||||
|
remoteAddr: "203.0.113.50:9999",
|
||||||
|
xff: "1.2.3.4, 10.0.0.1",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted RemoteAddr with single client in XFF",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "203.0.113.50",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("10.0.0.1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all XFF IPs trusted returns leftmost",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("10.0.0.2"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "XFF with whitespace",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: " 203.0.113.50 , 10.0.0.2 ",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "XFF with empty segments",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "203.0.113.50,,10.0.0.2",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-hop with mixed trust",
|
||||||
|
remoteAddr: "10.0.0.1:5000",
|
||||||
|
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemoteAddr without port",
|
||||||
|
remoteAddr: "10.0.0.1",
|
||||||
|
xff: "203.0.113.50",
|
||||||
|
list: trusted,
|
||||||
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, tt.list.ResolveClientIP(tt.remoteAddr, tt.xff))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user