Files
netbird/proxy/internal/proxy/target_resolver_test.go
T
Maycon Santos 1161616c02 [proxy] Enforce per-target HTTP access actions (#8115)
Apply target access before service authentication: inherit, bypass, or block. Keep route selection and auth decisions on the same snapshot; reject stale or unavailable required middleware.

Validation: full auth/proxy race tests and scoped lint passed. Runtime regression tests remain with this layer.
2026-10-12 12:31:57 +02:00

337 lines
12 KiB
Go

package proxy
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/middleware"
guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
)
type resolverRoundTripFunc func(*http.Request) (*http.Response, error)
func (f resolverRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func resolverMapping(targets map[string]*PathTarget) Mapping {
return Mapping{
ID: "service-1",
AccountID: "account-1",
Host: "example.com",
Paths: targets,
}
}
func mustTargetURL(t *testing.T, rawURL string) *url.URL {
t.Helper()
targetURL, err := url.Parse(rawURL)
require.NoError(t, err)
return targetURL
}
func TestTargetResolver_LongestPrefixPinsTargetAndAction(t *testing.T) {
mapping := resolverMapping(map[string]*PathTarget{
"/": {
URL: mustTargetURL(t, "http://root.internal"),
AccessAction: AccessActionInherit,
},
"/public": {
URL: mustTargetURL(t, "http://public.internal"),
AccessAction: AccessActionBypass,
CustomHeaders: map[string]string{"X-Target": "original"},
},
})
resolver, err := NewTargetResolver(mapping)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/public/status", nil)
resolvedReq, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionBypass, action, "the longest matching target action should be selected")
// Mutating both the source mapping and the live proxy mapping must not
// change the target already selected by the auth snapshot.
mapping.Paths["/public"].URL.Host = "mutated.internal"
mapping.Paths["/public"].CustomHeaders["X-Target"] = "mutated"
liveMapping := resolverMapping(map[string]*PathTarget{
"/": {URL: mustTargetURL(t, "http://replacement.internal")},
})
var gotHost, gotHeader string
rp := NewReverseProxy(resolverRoundTripFunc(func(req *http.Request) (*http.Response, error) {
gotHost = req.URL.Host
gotHeader = req.Header.Get("X-Target")
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}), "auto", nil, nil)
rp.AddMapping(liveMapping)
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, resolvedReq)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Equal(t, "public.internal", gotHost, "forwarding must reuse the target pinned before auth")
assert.Equal(t, "original", gotHeader, "resolver-owned target options must be immutable")
}
func TestTargetResolver_PinnedMissDoesNotFallBackToNewMapping(t *testing.T) {
resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
"/api": {URL: mustTargetURL(t, "http://api.internal")},
}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil)
resolvedReq, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionInherit, action)
called := false
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("unexpected forwarding")
}), "auto", nil, nil)
rp.AddMapping(resolverMapping(map[string]*PathTarget{
"/": {URL: mustTargetURL(t, "http://new.internal")},
}))
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, resolvedReq)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.False(t, called, "a miss pinned before auth must not be resolved again after an update")
}
func TestReverseProxy_UnpinnedNonDefaultMappingFailsClosed(t *testing.T) {
for _, requestPath := range []string{"/public", "/missing"} {
t.Run(requestPath, func(t *testing.T) {
called := false
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("unexpected forwarding")
}), "auto", nil, nil)
rp.AddMapping(resolverMapping(map[string]*PathTarget{
"/public": {
URL: mustTargetURL(t, "http://public.internal"),
AccessAction: AccessActionBypass,
},
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com"+requestPath, nil)
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, req)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code,
"both target hits and misses must require an auth-owned resolution")
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"),
"an unavailable routing snapshot must not be cached")
assert.False(t, called, "a target access action must never forward without an auth-owned resolution")
})
}
}
func TestReverseProxy_ConfiguredMiddlewareMissingChainFailsClosed(t *testing.T) {
called := false
manager := middleware.NewManager(0, nil, nil)
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("unexpected forwarding")
}), "auto", nil, nil, WithMiddlewareManager(manager))
rp.AddMapping(resolverMapping(map[string]*PathTarget{
"/": {
URL: mustTargetURL(t, "http://protected.internal"),
Middlewares: []middleware.Spec{{
ID: "required-policy",
Slot: middleware.SlotOnRequest,
Enabled: true,
}},
},
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, req)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
assert.False(t, called, "a missing configured policy chain must never become pass-through")
}
func TestReverseProxy_PinnedTargetFailsClosedAfterMiddlewareRemoval(t *testing.T) {
target := &PathTarget{
URL: mustTargetURL(t, "http://protected.internal"),
AccessAction: AccessActionInherit,
Middlewares: []middleware.Spec{{
ID: guardrail.ID,
Slot: middleware.SlotOnRequest,
Enabled: true,
}},
}
mapping := resolverMapping(map[string]*PathTarget{"/": target})
resolver, err := NewTargetResolver(mapping)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
resolvedReq, _, err := resolver.ResolveRequest(req)
require.NoError(t, err)
upstreamCalls := 0
registry := middleware.NewRegistry()
require.NoError(t, registry.Register(guardrail.Factory{}))
manager := middleware.NewManager(0, nil, nil)
manager.SetResolver(middleware.NewResolver(registry))
require.NoError(t, manager.Rebuild(string(mapping.ID), []middleware.PathTargetBinding{{
ServiceID: string(mapping.ID),
PathID: "/",
Specs: target.Middlewares,
}}))
require.NotNil(t, manager.ChainFor(string(mapping.ID), "/"), "the policy chain must exist before invalidation")
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
upstreamCalls++
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}), "auto", nil, nil, WithMiddlewareManager(manager))
before := httptest.NewRecorder()
rp.ServeHTTP(before, resolvedReq.Clone(resolvedReq.Context()))
require.Equal(t, http.StatusNoContent, before.Code, "the installed policy chain must allow forwarding")
require.Equal(t, 1, upstreamCalls, "the upstream must receive the request before invalidation")
manager.Invalidate(string(mapping.ID))
require.Nil(t, manager.ChainFor(string(mapping.ID), "/"), "invalidation must remove the installed chain")
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, resolvedReq)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code, "a removed policy chain must fail closed")
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), "a removed policy chain must not be cached")
assert.Equal(t, 1, upstreamCalls, "a stale pinned target must not bypass a removed policy chain")
}
func TestTargetResolver_RejectsUnsafePathsWhenActionsAreConfigured(t *testing.T) {
resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
"/public": {
URL: mustTargetURL(t, "http://public.internal"),
AccessAction: AccessActionBypass,
},
"/": {URL: mustTargetURL(t, "http://root.internal")},
}))
require.NoError(t, err)
tests := []struct {
name string
path string
rawPath string
}{
{name: "dot segment", path: "/public/../private"},
{name: "duplicate separator", path: "/public//private"},
{name: "backslash", path: `/public\private`},
{name: "path parameter", path: "/public;ignored/private"},
{name: "encoded query delimiter", path: "/public?ignored", rawPath: "/public%3fignored"},
{name: "encoded fragment delimiter", path: "/public#ignored", rawPath: "/public%23ignored"},
{name: "encoded slash", path: "/public/private", rawPath: "/public%2fprivate"},
{name: "encoded dot", path: "/public/../private", rawPath: "/public/%2e%2e/private"},
{name: "double encoding", path: "/public/%2e%2e/private", rawPath: "/public/%252e%252e/private"},
{name: "escaped NUL", path: "/public/\x00private", rawPath: "/public/%00private"},
{name: "mismatched raw path", path: "/public/safe", rawPath: "/different"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.URL.Path = tt.path
req.URL.RawPath = tt.rawPath
_, _, err := resolver.ResolveRequest(req)
require.ErrorIs(t, err, ErrUnsafeRequestPath)
})
}
req := httptest.NewRequest(http.MethodGet, "http://example.com/public/file%20name", nil)
_, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionBypass, action, "ordinary percent-encoded path data should remain supported")
}
func TestTargetResolver_InheritOnlyPreservesLegacyPathHandling(t *testing.T) {
resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
"/": {URL: mustTargetURL(t, "http://root.internal")},
}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.URL.Path = "/legacy/../path"
resolvedReq, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionInherit, action)
_, pinned := targetResolutionFromContext(resolvedReq.Context())
assert.True(t, pinned, "inherit-only mappings should still pin their target snapshot")
}
func TestNewTargetResolver_RejectsInvalidAccessActions(t *testing.T) {
tests := []struct {
name string
target *PathTarget
}{
{
name: "unknown action",
target: &PathTarget{
URL: mustTargetURL(t, "http://target.internal"),
AccessAction: AccessAction("unknown"),
},
},
{
name: "Agent Network bypass",
target: &PathTarget{
URL: mustTargetURL(t, "http://target.internal"),
AccessAction: AccessActionBypass,
AgentNetwork: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{"/": tt.target}))
require.Error(t, err)
})
}
}
func TestNewTargetResolver_RejectsNoncanonicalAccessPrefixes(t *testing.T) {
prefixes := []string{
"public",
"/public/../private",
"/public//nested",
`/public\nested`,
"/public;parameter",
"/public%2fprivate",
"/public?query",
"/public#fragment",
"/public\x00nested",
}
for _, prefix := range prefixes {
t.Run(prefix, func(t *testing.T) {
_, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
prefix: {
URL: mustTargetURL(t, "http://target.internal"),
AccessAction: AccessActionBlock,
},
"/": {URL: mustTargetURL(t, "http://root.internal")},
}))
require.ErrorIs(t, err, ErrUnsafeRequestPath)
})
}
}