mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 03:59:56 +00:00
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker
forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present — that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.
Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.
* fix(proxy): harden inbound listener resource + startup-ctx handling
Three correctness fixes on the per-account inbound path, with tests:
- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
back an *io.PipeWriter backed by a pipe + scanner goroutine that the
caller owns; the two writers per account (https + plain) were never
closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
is launched in a goroutine from AddPeer and was inheriting the
caller's request-scoped ctx, so a cancelled request could abort the
inbound bring-up or fail the management status notification. The
tail is split into notifyClientReady so the contract is testable.
Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.
* feat(proxy): short-circuit peer-own-target loops with 421
When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer — twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.
Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.
The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.
* fix(management): private-service validation + tunnel-IP lookup semantics
- Require an explicit port for L4 cluster targets. validateL4Target
exempted TargetTypeCluster from the port check, but buildPathMappings
serializes every L4 target via net.JoinHostPort(host, port) — port=0
shipped a ":0" upstream. Cluster targets use the same Host/Port
fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
every error to Internal. The proxy's ValidateTunnelPeer probes IPs
that legitimately aren't in the roster; the miss is expected and now
distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
request doesn't keep the store busy.
Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.
* fix(client): include offlinePeers in PeerStateByIP lookup
ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:
- causes the DNS filter to keep returning records pointing at peers
that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
peer rather than letting the cached management RPC carry the
authorisation decision.
Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.
* fix(rest): reject empty Delete path params in reverse-proxy clients
ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).
Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.
* chore(api,ci,docs,test): private-service schema, proto-check, fixups
Non-functional cleanups and contract/CI hardening around the
private-service work:
API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
on both Service and ServiceRequest, mirroring
validatePrivateRequirements. mode stays optional-but-constrained
(empty defaults to http server-side), matching runtime.
CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
-gen-go-grpc suffix) so grpc-generated files are in scope.
Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
(initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
comments and the proto doc.
Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.
* fix(proxy): background ctx for already-started AddPeer notification
The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.
Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.
* use the cmd context for roundtripper
1410 lines
52 KiB
Go
1410 lines
52 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/proxy/auth"
|
|
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
|
"github.com/netbirdio/netbird/proxy/web"
|
|
)
|
|
|
|
func TestRewriteFunc_HostRewriting(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
|
|
t.Run("rewrites host to backend by default", func(t *testing.T) {
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "backend.internal:8080", pr.Out.Host)
|
|
})
|
|
|
|
t.Run("preserves original host when passHostHeader is true", func(t *testing.T) {
|
|
rewrite := p.rewriteFunc(target, "", true, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "public.example.com", pr.Out.Host,
|
|
"Host header should be the original client host")
|
|
assert.Equal(t, "backend.internal:8080", pr.Out.URL.Host,
|
|
"URL host (used for TLS/SNI) must still point to the backend")
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_XForwardedForStripping(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
t.Run("sets X-Forwarded-For from direct connection IP", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"),
|
|
"should be set to the connecting client IP")
|
|
})
|
|
|
|
t.Run("strips spoofed X-Forwarded-For from client", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"),
|
|
"spoofed XFF must be replaced, not appended to")
|
|
})
|
|
|
|
t.Run("strips spoofed X-Real-IP from client", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set("X-Real-IP", "10.0.0.1")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"),
|
|
"spoofed X-Real-IP must be replaced")
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
|
|
t.Run("sets X-Forwarded-Host to original host", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://myapp.example.com:8443/path", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "myapp.example.com:8443", pr.Out.Header.Get("X-Forwarded-Host"))
|
|
})
|
|
|
|
t.Run("sets X-Forwarded-Port from explicit host port", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com:8443/path", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "8443", pr.Out.Header.Get("X-Forwarded-Port"))
|
|
})
|
|
|
|
t.Run("defaults X-Forwarded-Port to 443 for https", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000")
|
|
pr.In.TLS = &tls.ConnectionState{}
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "443", pr.Out.Header.Get("X-Forwarded-Port"))
|
|
})
|
|
|
|
t.Run("defaults X-Forwarded-Port to 80 for http", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "80", pr.Out.Header.Get("X-Forwarded-Port"))
|
|
})
|
|
|
|
t.Run("auto detects https from TLS", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000")
|
|
pr.In.TLS = &tls.ConnectionState{}
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto"))
|
|
})
|
|
|
|
t.Run("auto detects http without TLS", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto"))
|
|
})
|
|
|
|
t.Run("forced proto overrides TLS detection", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "https"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
// No TLS, but forced to https
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto"))
|
|
})
|
|
|
|
t.Run("forced http proto", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "http"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000")
|
|
pr.In.TLS = &tls.ConnectionState{}
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto"))
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_SessionCookieStripping(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
t.Run("strips nb_session cookie", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
pr.In.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "jwt-token-here"})
|
|
|
|
rewrite(pr)
|
|
|
|
cookies := pr.Out.Cookies()
|
|
for _, c := range cookies {
|
|
assert.NotEqual(t, auth.SessionCookieName, c.Name,
|
|
"proxy session cookie must not be forwarded to backend")
|
|
}
|
|
})
|
|
|
|
t.Run("preserves other cookies", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
pr.In.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "jwt-token"})
|
|
pr.In.AddCookie(&http.Cookie{Name: "app_session", Value: "app-value"})
|
|
pr.In.AddCookie(&http.Cookie{Name: "tracking", Value: "track-value"})
|
|
|
|
rewrite(pr)
|
|
|
|
cookies := pr.Out.Cookies()
|
|
cookieNames := make([]string, 0, len(cookies))
|
|
for _, c := range cookies {
|
|
cookieNames = append(cookieNames, c.Name)
|
|
}
|
|
assert.Contains(t, cookieNames, "app_session", "non-proxy cookies should be preserved")
|
|
assert.Contains(t, cookieNames, "tracking", "non-proxy cookies should be preserved")
|
|
assert.NotContains(t, cookieNames, auth.SessionCookieName, "proxy cookie must be stripped")
|
|
})
|
|
|
|
t.Run("handles request with no cookies", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Empty(t, pr.Out.Header.Get("Cookie"))
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
t.Run("strips session_token query parameter", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/callback?session_token=secret123&other=keep", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Empty(t, pr.Out.URL.Query().Get("session_token"),
|
|
"OIDC session token must be stripped from backend request")
|
|
assert.Equal(t, "keep", pr.Out.URL.Query().Get("other"),
|
|
"other query parameters must be preserved")
|
|
})
|
|
|
|
t.Run("preserves query when no session_token present", func(t *testing.T) {
|
|
pr := newProxyRequest(t, "http://example.com/api?foo=bar&baz=qux", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "bar", pr.Out.URL.Query().Get("foo"))
|
|
assert.Equal(t, "qux", pr.Out.URL.Query().Get("baz"))
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_URLRewriting(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
|
|
t.Run("rewrites URL to target with path prefix", func(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080/app")
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/somepath", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "http", pr.Out.URL.Scheme)
|
|
assert.Equal(t, "backend.internal:8080", pr.Out.URL.Host)
|
|
assert.Equal(t, "/app/somepath", pr.Out.URL.Path,
|
|
"SetURL should join the target base path with the request path")
|
|
})
|
|
|
|
t.Run("strips matched path prefix to avoid duplication", func(t *testing.T) {
|
|
target, _ := url.Parse("https://backend.example.org:443/app")
|
|
rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/app", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "https", pr.Out.URL.Scheme)
|
|
assert.Equal(t, "backend.example.org:443", pr.Out.URL.Host)
|
|
assert.Equal(t, "/app/", pr.Out.URL.Path,
|
|
"matched path prefix should be stripped before joining with target path")
|
|
})
|
|
|
|
t.Run("strips matched prefix and preserves subpath", func(t *testing.T) {
|
|
target, _ := url.Parse("https://backend.example.org:443/app")
|
|
rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/app/article/123", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/app/article/123", pr.Out.URL.Path,
|
|
"subpath after matched prefix should be preserved")
|
|
})
|
|
}
|
|
|
|
func TestExtractHostIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
remoteAddr string
|
|
expected netip.Addr
|
|
}{
|
|
{"IPv4 with port", "192.168.1.1:12345", netip.MustParseAddr("192.168.1.1")},
|
|
{"IPv6 with port", "[::1]:12345", netip.MustParseAddr("::1")},
|
|
{"IPv6 full with port", "[2001:db8::1]:443", netip.MustParseAddr("2001:db8::1")},
|
|
{"IPv4 without port fallback", "192.168.1.1", netip.MustParseAddr("192.168.1.1")},
|
|
{"IPv6 without brackets fallback", "::1", netip.MustParseAddr("::1")},
|
|
{"empty string fallback", "", netip.Addr{}},
|
|
{"public IP", "203.0.113.50:9999", netip.MustParseAddr("203.0.113.50")},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractForwardedPort(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
resolvedProto string
|
|
expected string
|
|
}{
|
|
{"explicit port in host", "example.com:8443", "https", "8443"},
|
|
{"explicit port overrides proto default", "example.com:9090", "http", "9090"},
|
|
{"no port defaults to 443 for https", "example.com", "https", "443"},
|
|
{"no port defaults to 80 for http", "example.com", "http", "80"},
|
|
{"IPv6 host with port", "[::1]:8080", "http", "8080"},
|
|
{"IPv6 host without port", "::1", "https", "443"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.expected, extractForwardedPort(tt.host, tt.resolvedProto))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRewriteFunc_TrustedProxy(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
|
|
|
t.Run("appends to X-Forwarded-For", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
pr.In.Header.Set("X-Forwarded-For", "203.0.113.50")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "203.0.113.50, 10.0.0.1", pr.Out.Header.Get("X-Forwarded-For"))
|
|
})
|
|
|
|
t.Run("preserves upstream X-Real-IP", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
pr.In.Header.Set("X-Forwarded-For", "203.0.113.50")
|
|
pr.In.Header.Set("X-Real-IP", "203.0.113.50")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"))
|
|
})
|
|
|
|
t.Run("resolves X-Real-IP from XFF when not set by upstream", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
pr.In.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.2")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"),
|
|
"should resolve real client through trusted chain")
|
|
})
|
|
|
|
t.Run("preserves upstream X-Forwarded-Host", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://proxy.internal/", "10.0.0.1:5000")
|
|
pr.In.Header.Set("X-Forwarded-Host", "original.example.com")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "original.example.com", pr.Out.Header.Get("X-Forwarded-Host"))
|
|
})
|
|
|
|
t.Run("preserves upstream X-Forwarded-Proto", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
pr.In.Header.Set("X-Forwarded-Proto", "https")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto"))
|
|
})
|
|
|
|
t.Run("preserves upstream X-Forwarded-Port", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
pr.In.Header.Set("X-Forwarded-Port", "8443")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "8443", pr.Out.Header.Get("X-Forwarded-Port"))
|
|
})
|
|
|
|
t.Run("falls back to local proto when upstream does not set it", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "https", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto"),
|
|
"should use configured forwardedProto as fallback")
|
|
})
|
|
|
|
t.Run("sets X-Forwarded-Host from request when upstream does not set it", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "example.com", pr.Out.Header.Get("X-Forwarded-Host"))
|
|
})
|
|
|
|
t.Run("untrusted RemoteAddr strips headers even with trusted list", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1")
|
|
pr.In.Header.Set("X-Real-IP", "evil")
|
|
pr.In.Header.Set("X-Forwarded-Host", "evil.example.com")
|
|
pr.In.Header.Set("X-Forwarded-Proto", "https")
|
|
pr.In.Header.Set("X-Forwarded-Port", "9999")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"),
|
|
"untrusted: XFF must be replaced")
|
|
assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"),
|
|
"untrusted: X-Real-IP must be replaced")
|
|
assert.Equal(t, "example.com", pr.Out.Header.Get("X-Forwarded-Host"),
|
|
"untrusted: host must be from direct connection")
|
|
assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto"),
|
|
"untrusted: proto must be locally resolved")
|
|
assert.Equal(t, "80", pr.Out.Header.Get("X-Forwarded-Port"),
|
|
"untrusted: port must be locally computed")
|
|
})
|
|
|
|
t.Run("empty trusted list behaves as untrusted", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: nil}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
pr.In.Header.Set("X-Forwarded-For", "203.0.113.50")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "10.0.0.1", pr.Out.Header.Get("X-Forwarded-For"),
|
|
"nil trusted list: should strip and use RemoteAddr")
|
|
})
|
|
|
|
t.Run("XFF starts fresh when trusted proxy has no upstream XFF", func(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "10.0.0.1", pr.Out.Header.Get("X-Forwarded-For"),
|
|
"no upstream XFF: should set direct connection IP")
|
|
})
|
|
}
|
|
|
|
// TestRewriteFunc_PathForwarding verifies what path the backend actually
|
|
// receives given different configurations. This simulates the full pipeline:
|
|
// management builds a target URL (with matching prefix baked into the path),
|
|
// then the proxy strips the prefix and SetURL re-joins with the target path.
|
|
func TestRewriteFunc_PathForwarding(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
|
|
// Simulate what ToProtoMapping does: target URL includes the matching
|
|
// prefix as its path component, so the proxy strips-then-re-adds.
|
|
t.Run("path prefix baked into target URL is a no-op", func(t *testing.T) {
|
|
// Management builds: path="/heise", target="https://heise.de:443/heise"
|
|
target, _ := url.Parse("https://heise.de:443/heise")
|
|
rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/heise/", pr.Out.URL.Path,
|
|
"backend sees /heise/ because prefix is stripped then re-added by SetURL")
|
|
})
|
|
|
|
t.Run("subpath under prefix also preserved", func(t *testing.T) {
|
|
target, _ := url.Parse("https://heise.de:443/heise")
|
|
rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/heise/article/123", pr.Out.URL.Path,
|
|
"subpath is preserved on top of the re-added prefix")
|
|
})
|
|
|
|
// What the behavior WOULD be if target URL had no path (true stripping)
|
|
t.Run("target without path prefix gives true stripping", func(t *testing.T) {
|
|
target, _ := url.Parse("https://heise.de:443")
|
|
rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/", pr.Out.URL.Path,
|
|
"without path in target URL, backend sees / (true prefix stripping)")
|
|
})
|
|
|
|
t.Run("target without path prefix strips and preserves subpath", func(t *testing.T) {
|
|
target, _ := url.Parse("https://heise.de:443")
|
|
rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/article/123", pr.Out.URL.Path,
|
|
"without path in target URL, prefix is truly stripped")
|
|
})
|
|
|
|
// Root path "/" — no stripping expected
|
|
t.Run("root path forwards full request path unchanged", func(t *testing.T) {
|
|
target, _ := url.Parse("https://backend.example.com:443/")
|
|
rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/heise", pr.Out.URL.Path,
|
|
"root path match must not strip anything")
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_PreservePath(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
|
|
t.Run("preserve keeps full request path", func(t *testing.T) {
|
|
rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/api/users/123", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/api/users/123", pr.Out.URL.Path,
|
|
"preserve should keep the full original request path")
|
|
})
|
|
|
|
t.Run("preserve with root matchedPath", func(t *testing.T) {
|
|
rewrite := p.rewriteFunc(target, "/", false, PathRewritePreserve, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/anything", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/anything", pr.Out.URL.Path)
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_CustomHeaders(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
|
|
t.Run("injects custom headers", func(t *testing.T) {
|
|
headers := map[string]string{
|
|
"X-Custom-Auth": "token-abc",
|
|
"X-Env": "production",
|
|
}
|
|
rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil)
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "token-abc", pr.Out.Header.Get("X-Custom-Auth"))
|
|
assert.Equal(t, "production", pr.Out.Header.Get("X-Env"))
|
|
})
|
|
|
|
t.Run("nil customHeaders is fine", func(t *testing.T) {
|
|
rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil)
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "backend.internal:8080", pr.Out.Host)
|
|
})
|
|
|
|
t.Run("custom headers override existing request headers", func(t *testing.T) {
|
|
headers := map[string]string{"X-Override": "new-value"}
|
|
rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil)
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
pr.In.Header.Set("X-Override", "old-value")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "new-value", pr.Out.Header.Get("X-Override"))
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_StripsAuthorizationHeader(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
|
|
t.Run("strips incoming Authorization when no custom Authorization set", func(t *testing.T) {
|
|
rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, []string{"Authorization"})
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
pr.In.Header.Set("Authorization", "Bearer proxy-token")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Empty(t, pr.Out.Header.Get("Authorization"), "Authorization should be stripped")
|
|
})
|
|
|
|
t.Run("custom Authorization replaces incoming", func(t *testing.T) {
|
|
headers := map[string]string{"Authorization": "Basic YmFja2VuZDpzZWNyZXQ="}
|
|
rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, []string{"Authorization"})
|
|
pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000")
|
|
pr.In.Header.Set("Authorization", "Bearer proxy-token")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "Basic YmFja2VuZDpzZWNyZXQ=", pr.Out.Header.Get("Authorization"),
|
|
"backend Authorization from custom headers should be set")
|
|
})
|
|
}
|
|
|
|
func TestRewriteFunc_PreservePathWithCustomHeaders(t *testing.T) {
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
|
|
rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, map[string]string{"X-Via": "proxy"}, nil)
|
|
pr := newProxyRequest(t, "http://example.com/api/deep/path", "1.2.3.4:5000")
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "/api/deep/path", pr.Out.URL.Path, "preserve should keep the full original path")
|
|
assert.Equal(t, "proxy", pr.Out.Header.Get("X-Via"), "custom header should be set")
|
|
}
|
|
|
|
func TestRewriteLocationFunc(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
newProxy := func(proto string) *ReverseProxy { return &ReverseProxy{forwardedProto: proto} }
|
|
newReq := func(rawURL string) *http.Request {
|
|
t.Helper()
|
|
r := httptest.NewRequest(http.MethodGet, rawURL, nil)
|
|
parsed, _ := url.Parse(rawURL)
|
|
r.Host = parsed.Host
|
|
return r
|
|
}
|
|
run := func(p *ReverseProxy, matchedPath string, inReq *http.Request, location string) (*http.Response, error) {
|
|
t.Helper()
|
|
modifyResp := p.rewriteLocationFunc(target, matchedPath, inReq) //nolint:bodyclose
|
|
resp := &http.Response{Header: http.Header{}}
|
|
if location != "" {
|
|
resp.Header.Set("Location", location)
|
|
}
|
|
err := modifyResp(resp)
|
|
return resp, err
|
|
}
|
|
|
|
t.Run("rewrites Location pointing to backend", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/page"), //nolint:bodyclose
|
|
"http://backend.internal:8080/login")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/login", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("does not rewrite Location pointing to other host", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"https://other.example.com/path")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://other.example.com/path", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("does not rewrite relative Location", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"/dashboard")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/dashboard", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("re-adds stripped path prefix", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "/api", newReq("https://public.example.com/api/users"), //nolint:bodyclose
|
|
"http://backend.internal:8080/users")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/api/users", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("uses resolved proto for scheme", func(t *testing.T) {
|
|
resp, err := run(newProxy("auto"), "", newReq("http://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/path")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "http://public.example.com/path", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("no-op when Location header is empty", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), "") //nolint:bodyclose
|
|
|
|
require.NoError(t, err)
|
|
assert.Empty(t, resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("does not prepend root path prefix", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "/", newReq("https://public.example.com/login"), //nolint:bodyclose
|
|
"http://backend.internal:8080/login")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/login", resp.Header.Get("Location"))
|
|
})
|
|
|
|
// --- Edge cases: query parameters and fragments ---
|
|
|
|
t.Run("preserves query parameters", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/login?redirect=%2Fdashboard&lang=en")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/login?redirect=%2Fdashboard&lang=en", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("preserves fragment", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/docs#section-2")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/docs#section-2", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("preserves query parameters and fragment together", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/search?q=test&page=1#results")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/search?q=test&page=1#results", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("preserves query parameters with path prefix re-added", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "/api", newReq("https://public.example.com/api/search"), //nolint:bodyclose
|
|
"http://backend.internal:8080/search?q=hello")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/api/search?q=hello", resp.Header.Get("Location"))
|
|
})
|
|
|
|
// --- Edge cases: slash handling ---
|
|
|
|
t.Run("no double slash when matchedPath has trailing slash", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "/api/", newReq("https://public.example.com/api/users"), //nolint:bodyclose
|
|
"http://backend.internal:8080/users")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/api/users", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("backend redirect to root with path prefix", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "/app", newReq("https://public.example.com/app/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/app/", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("backend redirect to root with trailing-slash path prefix", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "/app/", newReq("https://public.example.com/app/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/app/", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("preserves trailing slash on redirect path", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/path/")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/path/", resp.Header.Get("Location"))
|
|
})
|
|
|
|
t.Run("backend redirect to bare root", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/page"), //nolint:bodyclose
|
|
"http://backend.internal:8080/")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/", resp.Header.Get("Location"))
|
|
})
|
|
|
|
// --- Edge cases: host/port matching ---
|
|
|
|
t.Run("does not rewrite when backend host matches but port differs", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal:9090/other")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "http://backend.internal:9090/other", resp.Header.Get("Location"),
|
|
"Different port means different host authority, must not rewrite")
|
|
})
|
|
|
|
t.Run("rewrites when redirect omits default port matching target", func(t *testing.T) {
|
|
// Target is backend.internal:8080, redirect is to backend.internal (no port).
|
|
// These are different authorities, so should NOT rewrite.
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal/path")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "http://backend.internal/path", resp.Header.Get("Location"),
|
|
"backend.internal != backend.internal:8080, must not rewrite")
|
|
})
|
|
|
|
t.Run("rewrites when target has :443 but redirect omits it for https", func(t *testing.T) {
|
|
// Target: heise.de:443, redirect: https://heise.de/path (no :443 because it's default)
|
|
// Per RFC 3986, these are the same authority.
|
|
target443, _ := url.Parse("https://heise.de:443")
|
|
p := newProxy("https")
|
|
modifyResp := p.rewriteLocationFunc(target443, "", newReq("https://public.example.com/")) //nolint:bodyclose
|
|
resp := &http.Response{Header: http.Header{}}
|
|
resp.Header.Set("Location", "https://heise.de/path")
|
|
|
|
err := modifyResp(resp)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/path", resp.Header.Get("Location"),
|
|
"heise.de:443 and heise.de are the same for https")
|
|
})
|
|
|
|
t.Run("rewrites when target has :80 but redirect omits it for http", func(t *testing.T) {
|
|
target80, _ := url.Parse("http://backend.local:80")
|
|
p := newProxy("http")
|
|
modifyResp := p.rewriteLocationFunc(target80, "", newReq("http://public.example.com/")) //nolint:bodyclose
|
|
resp := &http.Response{Header: http.Header{}}
|
|
resp.Header.Set("Location", "http://backend.local/path")
|
|
|
|
err := modifyResp(resp)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "http://public.example.com/path", resp.Header.Get("Location"),
|
|
"backend.local:80 and backend.local are the same for http")
|
|
})
|
|
|
|
t.Run("rewrites when redirect has :443 but target omits it", func(t *testing.T) {
|
|
targetNoPort, _ := url.Parse("https://heise.de")
|
|
p := newProxy("https")
|
|
modifyResp := p.rewriteLocationFunc(targetNoPort, "", newReq("https://public.example.com/")) //nolint:bodyclose
|
|
resp := &http.Response{Header: http.Header{}}
|
|
resp.Header.Set("Location", "https://heise.de:443/path")
|
|
|
|
err := modifyResp(resp)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/path", resp.Header.Get("Location"),
|
|
"heise.de and heise.de:443 are the same for https")
|
|
})
|
|
|
|
t.Run("does not conflate non-default ports", func(t *testing.T) {
|
|
target8443, _ := url.Parse("https://backend.internal:8443")
|
|
p := newProxy("https")
|
|
modifyResp := p.rewriteLocationFunc(target8443, "", newReq("https://public.example.com/")) //nolint:bodyclose
|
|
resp := &http.Response{Header: http.Header{}}
|
|
resp.Header.Set("Location", "https://backend.internal/path")
|
|
|
|
err := modifyResp(resp)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://backend.internal/path", resp.Header.Get("Location"),
|
|
"backend.internal:8443 != backend.internal (port 443), must not rewrite")
|
|
})
|
|
|
|
// --- Edge cases: encoded paths ---
|
|
|
|
t.Run("preserves percent-encoded path segments", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/path%20with%20spaces/file%2Fname")
|
|
|
|
require.NoError(t, err)
|
|
loc := resp.Header.Get("Location")
|
|
assert.Contains(t, loc, "public.example.com")
|
|
parsed, err := url.Parse(loc)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/path with spaces/file/name", parsed.Path)
|
|
})
|
|
|
|
t.Run("preserves encoded query parameters with path prefix", func(t *testing.T) {
|
|
resp, err := run(newProxy("https"), "/v1", newReq("https://public.example.com/v1/"), //nolint:bodyclose
|
|
"http://backend.internal:8080/redirect?url=http%3A%2F%2Fexample.com")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://public.example.com/v1/redirect?url=http%3A%2F%2Fexample.com", resp.Header.Get("Location"))
|
|
})
|
|
}
|
|
|
|
// newProxyRequest creates an httputil.ProxyRequest suitable for testing
|
|
// the Rewrite function. It simulates what httputil.ReverseProxy does internally:
|
|
// Out is a shallow clone of In with headers copied.
|
|
func newProxyRequest(t *testing.T, rawURL, remoteAddr string) *httputil.ProxyRequest {
|
|
t.Helper()
|
|
|
|
parsed, err := url.Parse(rawURL)
|
|
require.NoError(t, err)
|
|
|
|
in := httptest.NewRequest(http.MethodGet, rawURL, nil)
|
|
in.RemoteAddr = remoteAddr
|
|
in.Host = parsed.Host
|
|
|
|
out := in.Clone(in.Context())
|
|
out.Header = in.Header.Clone()
|
|
|
|
return &httputil.ProxyRequest{In: in, Out: out}
|
|
}
|
|
|
|
func TestClassifyProxyError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantTitle string
|
|
wantCode int
|
|
wantStatus web.ErrorStatus
|
|
}{
|
|
{
|
|
name: "context deadline exceeded",
|
|
err: context.DeadlineExceeded,
|
|
wantTitle: "Request Timeout",
|
|
wantCode: http.StatusGatewayTimeout,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "wrapped deadline exceeded",
|
|
err: fmt.Errorf("dial: %w", context.DeadlineExceeded),
|
|
wantTitle: "Request Timeout",
|
|
wantCode: http.StatusGatewayTimeout,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "context canceled",
|
|
err: context.Canceled,
|
|
wantTitle: "Request Canceled",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "no account ID",
|
|
err: roundtrip.ErrNoAccountID,
|
|
wantTitle: "Configuration Error",
|
|
wantCode: http.StatusInternalServerError,
|
|
wantStatus: web.ErrorStatus{Proxy: false, Destination: false},
|
|
},
|
|
{
|
|
name: "no peer connection",
|
|
err: fmt.Errorf("%w for account: abc", roundtrip.ErrNoPeerConnection),
|
|
wantTitle: "Proxy Not Connected",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: false, Destination: false},
|
|
},
|
|
{
|
|
name: "client not started",
|
|
err: fmt.Errorf("%w: %w", roundtrip.ErrClientStartFailed, errors.New("engine init failed")),
|
|
wantTitle: "Proxy Not Connected",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: false, Destination: false},
|
|
},
|
|
{
|
|
name: "syscall ECONNREFUSED via os.SyscallError",
|
|
err: &net.OpError{
|
|
Op: "dial",
|
|
Net: "tcp",
|
|
Err: &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED},
|
|
},
|
|
wantTitle: "Service Unavailable",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "gvisor connection was refused",
|
|
err: &net.OpError{
|
|
Op: "connect",
|
|
Net: "tcp",
|
|
Err: errors.New("connection was refused"),
|
|
},
|
|
wantTitle: "Service Unavailable",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "syscall EHOSTUNREACH via os.SyscallError",
|
|
err: &net.OpError{
|
|
Op: "dial",
|
|
Net: "tcp",
|
|
Err: &os.SyscallError{Syscall: "connect", Err: syscall.EHOSTUNREACH},
|
|
},
|
|
wantTitle: "Peer Not Connected",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "syscall ENETUNREACH via os.SyscallError",
|
|
err: &net.OpError{
|
|
Op: "dial",
|
|
Net: "tcp",
|
|
Err: &os.SyscallError{Syscall: "connect", Err: syscall.ENETUNREACH},
|
|
},
|
|
wantTitle: "Peer Not Connected",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "gvisor host is unreachable",
|
|
err: &net.OpError{
|
|
Op: "connect",
|
|
Net: "tcp",
|
|
Err: errors.New("host is unreachable"),
|
|
},
|
|
wantTitle: "Peer Not Connected",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "gvisor network is unreachable",
|
|
err: &net.OpError{
|
|
Op: "connect",
|
|
Net: "tcp",
|
|
Err: errors.New("network is unreachable"),
|
|
},
|
|
wantTitle: "Peer Not Connected",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "standard no route to host",
|
|
err: &net.OpError{
|
|
Op: "dial",
|
|
Net: "tcp",
|
|
Err: &os.SyscallError{Syscall: "connect", Err: syscall.EHOSTUNREACH},
|
|
},
|
|
wantTitle: "Peer Not Connected",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
{
|
|
name: "unknown error falls to default",
|
|
err: errors.New("something unexpected"),
|
|
wantTitle: "Connection Error",
|
|
wantCode: http.StatusBadGateway,
|
|
wantStatus: web.ErrorStatus{Proxy: true, Destination: false},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
title, _, code, status := classifyProxyError(tt.err)
|
|
assert.Equal(t, tt.wantTitle, title, "title")
|
|
assert.Equal(t, tt.wantCode, code, "status code")
|
|
assert.Equal(t, tt.wantStatus, status, "component status")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStampNetBirdIdentity_NoCapturedData_StripsOnly(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
|
|
pr.In.Header.Set(headerNetBirdGroups, "admin")
|
|
pr.Out.Header = pr.In.Header.Clone()
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
|
|
"client-supplied X-NetBird-User must be stripped when no captured identity is present")
|
|
assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
|
|
"client-supplied X-NetBird-Groups must be stripped when no captured identity is present")
|
|
}
|
|
|
|
func TestStampNetBirdIdentity_StampsFromCapturedData(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
|
|
pr.Out.Header = pr.In.Header.Clone()
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserEmail("alice@netbird.io")
|
|
cd.SetUserGroups([]string{"grp-eng", "grp-ops"})
|
|
cd.SetUserGroupNames([]string{"engineering", "operations"})
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "alice@netbird.io", pr.Out.Header.Get(headerNetBirdUser),
|
|
"captured email must overwrite any spoofed value")
|
|
assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups),
|
|
"group display names must be CSV-joined in positional order")
|
|
}
|
|
|
|
// TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty covers the
|
|
// tunnel-peer-without-user case (machine agents, unattached proxy peers).
|
|
// The proxy must still stamp the peer's groups so downstream services can
|
|
// authorise, but X-NetBird-User stays unset — only its inbound stripping
|
|
// must happen.
|
|
func TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
|
|
pr.Out.Header = pr.In.Header.Clone()
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserGroups([]string{"grp-machines"})
|
|
cd.SetUserGroupNames([]string{"machines"})
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
|
|
"X-NetBird-User must remain unset when CapturedData carries no email")
|
|
assert.Equal(t, "machines", pr.Out.Header.Get(headerNetBirdGroups),
|
|
"groups must still be stamped for peers without a user identity")
|
|
}
|
|
|
|
// TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty covers the symmetric
|
|
// case: identity-resolved user without resolved group memberships.
|
|
func TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
|
|
pr.Out.Header = pr.In.Header.Clone()
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserEmail("carol@netbird.io")
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "carol@netbird.io", pr.Out.Header.Get(headerNetBirdUser),
|
|
"email must be stamped even when no groups are captured")
|
|
assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
|
|
"X-NetBird-Groups must remain unset when CapturedData carries no groups")
|
|
}
|
|
|
|
func TestStampNetBirdIdentity_FallsBackToGroupIDsWhenNameMissing(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserEmail("bob@netbird.io")
|
|
cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"})
|
|
// "grp-b" gets an explicit empty-string display name (not just a
|
|
// shorter slice). Both gap shapes must fall back to the id.
|
|
cd.SetUserGroupNames([]string{"alpha", "", ""})
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "alpha,grp-b,grp-c", pr.Out.Header.Get(headerNetBirdGroups),
|
|
"empty-string and out-of-range name slots must both fall back to the group id")
|
|
}
|
|
|
|
// TestStampNetBirdIdentity_DropsLabelsWithComma covers the
|
|
// comma-separator constraint: a group display name that itself contains
|
|
// a comma is dropped from the header (rather than corrupting the list),
|
|
// and the remaining labels are stamped.
|
|
func TestStampNetBirdIdentity_DropsLabelsWithComma(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserEmail("alice@netbird.io")
|
|
cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"})
|
|
cd.SetUserGroupNames([]string{"engineering", "EU, EMEA", "operations"})
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups),
|
|
"group label with embedded comma must be dropped, remaining labels stamped")
|
|
}
|
|
|
|
// TestStampNetBirdIdentity_RejectsControlCharsInEmail covers the
|
|
// header-injection defence: an email value containing CR/LF/control
|
|
// chars is omitted entirely (not partially stamped) so the upstream
|
|
// request stays well-formed and no header injection is possible.
|
|
func TestStampNetBirdIdentity_RejectsControlCharsInEmail(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
|
|
pr.Out.Header = pr.In.Header.Clone()
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserEmail("alice@netbird.io\r\nX-Admin: yes")
|
|
cd.SetUserGroups([]string{"grp-a"})
|
|
cd.SetUserGroupNames([]string{"engineering"})
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
|
|
"email with CR/LF must be dropped, not partially stamped")
|
|
assert.Equal(t, "engineering", pr.Out.Header.Get(headerNetBirdGroups),
|
|
"groups remain stampable even when email is invalid")
|
|
}
|
|
|
|
// TestStampNetBirdIdentity_RejectsControlCharsInGroup covers the
|
|
// per-label defence: a group name with a control char is silently
|
|
// dropped, the rest are stamped.
|
|
func TestStampNetBirdIdentity_RejectsControlCharsInGroup(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserEmail("alice@netbird.io")
|
|
cd.SetUserGroups([]string{"grp-a", "grp-b"})
|
|
cd.SetUserGroupNames([]string{"engineering\r\nsneaky", "operations"})
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Equal(t, "operations", pr.Out.Header.Get(headerNetBirdGroups),
|
|
"group label with control char must be dropped, valid ones kept")
|
|
}
|
|
|
|
// TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid covers the
|
|
// edge case where every group label is rejected: the header must not be
|
|
// set at all (rather than set to an empty string).
|
|
func TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
|
|
pr.Out.Header = pr.In.Header.Clone()
|
|
|
|
cd := NewCapturedData("req-1")
|
|
cd.SetUserEmail("alice@netbird.io")
|
|
cd.SetUserGroups([]string{"grp-a", "grp-b"})
|
|
cd.SetUserGroupNames([]string{"with,comma", "with\nbreak"})
|
|
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
_, present := pr.Out.Header[http.CanonicalHeaderKey(headerNetBirdGroups)]
|
|
assert.False(t, present,
|
|
"X-NetBird-Groups must not be set when every group label is rejected")
|
|
}
|
|
|
|
// nopOKTransport returns 200 for every request without dialing — used
|
|
// by the self-target-loop tests so the non-loop cases don't pay a real
|
|
// TCP-dial timeout.
|
|
type nopOKTransport struct{}
|
|
|
|
func (nopOKTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: http.Header{}}, nil
|
|
}
|
|
|
|
// TestServeHTTP_SelfTargetLoopReturns421 covers the loop guard for
|
|
// private services: when a peer dials a service whose only target is
|
|
// the peer itself, the proxy must refuse with 421 (Misdirected
|
|
// Request) rather than round-tripping the request back over WG to
|
|
// the same peer.
|
|
func TestServeHTTP_SelfTargetLoopReturns421(t *testing.T) {
|
|
rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
|
|
rp.AddMapping(Mapping{
|
|
ID: "svc-1",
|
|
AccountID: "acct-1",
|
|
Host: "private.svc",
|
|
Paths: map[string]*PathTarget{
|
|
"/": {
|
|
URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
|
|
},
|
|
},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
|
|
req.Host = "private.svc"
|
|
req.RemoteAddr = "100.64.0.5:55555"
|
|
req = req.WithContext(types.WithOverlayOrigin(req.Context()))
|
|
rec := httptest.NewRecorder()
|
|
|
|
rp.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusMisdirectedRequest, rec.Code,
|
|
"a peer dialing a service whose target is itself must get 421")
|
|
}
|
|
|
|
// TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough verifies
|
|
// the guard is scoped to overlay-origin requests. A public-listener
|
|
// request that happens to share a source IP with the target host must
|
|
// not be misinterpreted as a loop — the gating relies on the inbound
|
|
// marker being attached only by the per-account overlay listener.
|
|
func TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough(t *testing.T) {
|
|
rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
|
|
rp.AddMapping(Mapping{
|
|
ID: "svc-1",
|
|
AccountID: "acct-1",
|
|
Host: "public.svc",
|
|
Paths: map[string]*PathTarget{
|
|
"/": {
|
|
URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
|
|
},
|
|
},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://public.svc/", nil)
|
|
req.Host = "public.svc"
|
|
req.RemoteAddr = "100.64.0.5:55555"
|
|
// No WithOverlayOrigin → the guard must not fire.
|
|
rec := httptest.NewRecorder()
|
|
|
|
rp.ServeHTTP(rec, req)
|
|
|
|
assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
|
|
"a non-overlay request with a colliding source IP must not be flagged as a loop")
|
|
}
|
|
|
|
// TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough confirms
|
|
// that overlay-origin requests with a source IP that does *not* match
|
|
// the target host are forwarded normally.
|
|
func TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough(t *testing.T) {
|
|
rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
|
|
rp.AddMapping(Mapping{
|
|
ID: "svc-1",
|
|
AccountID: "acct-1",
|
|
Host: "private.svc",
|
|
Paths: map[string]*PathTarget{
|
|
"/": {
|
|
URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
|
|
},
|
|
},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
|
|
req.Host = "private.svc"
|
|
req.RemoteAddr = "100.64.0.99:55555" // different from the target
|
|
req = req.WithContext(types.WithOverlayOrigin(req.Context()))
|
|
rec := httptest.NewRecorder()
|
|
|
|
rp.ServeHTTP(rec, req)
|
|
|
|
assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
|
|
"overlay request with a non-matching source IP must not be flagged as a loop")
|
|
}
|
|
|
|
// TestStampNetBirdIdentity_CapturedDataPresentButEmpty covers requests
|
|
// that carry CapturedData with no identity fields populated (e.g. the
|
|
// auth middleware ran but the request didn't authenticate). Both
|
|
// headers must be cleared and neither stamped.
|
|
func TestStampNetBirdIdentity_CapturedDataPresentButEmpty(t *testing.T) {
|
|
target, _ := url.Parse("http://backend.internal:8080")
|
|
p := &ReverseProxy{forwardedProto: "auto"}
|
|
rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
|
|
|
|
pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
|
|
pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
|
|
pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
|
|
pr.Out.Header = pr.In.Header.Clone()
|
|
|
|
cd := NewCapturedData("req-1")
|
|
pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
|
|
|
|
rewrite(pr)
|
|
|
|
assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
|
|
"X-NetBird-User must be stripped when CapturedData has no email")
|
|
assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
|
|
"X-NetBird-Groups must be stripped when CapturedData has no groups")
|
|
}
|