mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 17:09:07 +02:00
Verify access decisions through real management and proxy containers, including upstream delivery and denial. Cover inherited auth, bypass/block, precedence, credential stripping, updates, restrictions, deletion, and private-service rejection. Validation: container E2E and scoped lint passed locally. The workflow builds both components from the checkout.
337 lines
13 KiB
Go
337 lines
13 KiB
Go
//go:build e2e
|
|
|
|
package reverseproxy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/e2e/harness"
|
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
|
)
|
|
|
|
const (
|
|
targetAccessDomain = "target-access." + harness.AgentNetworkCluster
|
|
targetAccessHeader = "X-E2E-Proxy-Key"
|
|
targetAccessSecret = "e2e-target-access-secret"
|
|
targetAccessEchoPrefix = "/e2e/proxy-echo/"
|
|
targetAccessApplicationAuth = "Bearer e2e-application-token"
|
|
targetAccessApplicationCookie = "application_session=e2e-visible"
|
|
)
|
|
|
|
type targetAccessEcho struct {
|
|
Source string `json:"source"`
|
|
Path string `json:"path"`
|
|
RequestURI string `json:"request_uri"`
|
|
ConfiguredAuth string `json:"configured_auth"`
|
|
Authorization string `json:"authorization"`
|
|
Cookie string `json:"cookie"`
|
|
NetBirdUser string `json:"netbird_user"`
|
|
NetBirdGroups string `json:"netbird_groups"`
|
|
}
|
|
|
|
func TestTargetAccessControl_PrivateServiceRejectsBypass(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
group, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-private-target-access"})
|
|
require.NoError(t, err, "create private-service access group")
|
|
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), group.Id) })
|
|
|
|
request := targetAccessServiceRequest(t, "http://upstream.invalid:80",
|
|
api.ServiceTargetAccessActionBypass,
|
|
api.ServiceTargetAccessActionBlock,
|
|
nil,
|
|
)
|
|
private := true
|
|
accessGroups := []string{group.Id}
|
|
request.Domain = "private-target-access." + harness.AgentNetworkCluster
|
|
request.Name = "E2E private target access rejection"
|
|
request.Private = &private
|
|
request.AccessGroups = &accessGroups
|
|
|
|
created, err := srv.API().ReverseProxyServices.Create(ctx, request)
|
|
if created != nil {
|
|
t.Cleanup(func() { _ = srv.API().ReverseProxyServices.Delete(context.Background(), created.Id) })
|
|
}
|
|
assert.Nil(t, created)
|
|
assert.ErrorContains(t, err, "bypass access_action is not supported for private services")
|
|
}
|
|
|
|
func TestTargetAccessControl_PublicServiceLifecycle(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
upstream, err := harness.StartHTTPUpstream(ctx, srv)
|
|
require.NoError(t, err, "start HTTP upstream")
|
|
t.Cleanup(func() { _ = upstream.Terminate(context.Background()) })
|
|
|
|
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-target-access-proxy")
|
|
require.NoError(t, err, "mint global proxy token")
|
|
// Cluster direct-upstream targets require a private-capable proxy, while the
|
|
// service itself remains public through request.Private below.
|
|
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
|
require.NoError(t, err, "start reverse proxy")
|
|
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
|
|
|
request := targetAccessServiceRequest(t, upstream.URL,
|
|
api.ServiceTargetAccessActionBypass,
|
|
api.ServiceTargetAccessActionBlock,
|
|
nil,
|
|
)
|
|
created, err := srv.API().ReverseProxyServices.Create(ctx, request)
|
|
require.NoError(t, err, "create service with per-target access actions")
|
|
removed := false
|
|
t.Cleanup(func() {
|
|
if !removed {
|
|
_ = srv.API().ReverseProxyServices.Delete(context.Background(), created.Id)
|
|
}
|
|
})
|
|
|
|
validAuth := targetAccessForwardedHeaders(targetAccessSecret)
|
|
wrongAuth := http.Header{targetAccessHeader: []string{"wrong-secret"}}
|
|
spoofedBypass := targetAccessForwardedHeaders("wrong-secret")
|
|
|
|
// Wait for the service snapshot before issuing unique one-shot markers. A
|
|
// marker used while polling could reach an older mapping and make an updated
|
|
// block look as though it forwarded after convergence.
|
|
requireProxyStatus(t, ctx, px, targetAccessPath("", "warm-created"), nil,
|
|
http.StatusUnauthorized, "wait for created service")
|
|
requireProxyAttempt(t, ctx, px, upstream, "", "inherit-missing", nil,
|
|
http.StatusUnauthorized, 0, "inherit target without service credentials")
|
|
requireProxyAttempt(t, ctx, px, upstream, "", "inherit-wrong", wrongAuth,
|
|
http.StatusUnauthorized, 0, "inherit target with invalid service credentials")
|
|
requireProxyEcho(t, ctx, px, upstream, "", "inherit-valid", validAuth,
|
|
"inherit target with valid service credentials")
|
|
requireProxyEcho(t, ctx, px, upstream, "/public", "bypass-initial", spoofedBypass,
|
|
"bypass target without valid service credentials")
|
|
requireProxyAttempt(t, ctx, px, upstream, "/public/private", "nested-missing", nil,
|
|
http.StatusUnauthorized, 0, "nested inherit target overrides bypass parent")
|
|
requireProxyEcho(t, ctx, px, upstream, "/public/private", "nested-valid", validAuth,
|
|
"nested inherit target accepts valid service credentials")
|
|
requireProxyAttempt(t, ctx, px, upstream, "/blocked", "block-initial", validAuth,
|
|
http.StatusForbidden, 0, "block target even with valid service credentials")
|
|
|
|
request = targetAccessServiceRequest(t, upstream.URL,
|
|
api.ServiceTargetAccessActionBlock,
|
|
api.ServiceTargetAccessActionBypass,
|
|
nil,
|
|
)
|
|
_, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request)
|
|
require.NoError(t, err, "swap target access actions")
|
|
requireProxyStatus(t, ctx, px, targetAccessPath("/public", "warm-actions-updated"), validAuth,
|
|
http.StatusForbidden, "wait for target action update")
|
|
requireProxyAttempt(t, ctx, px, upstream, "/public", "public-updated-block", validAuth,
|
|
http.StatusForbidden, 0, "updated public target is blocked")
|
|
requireProxyEcho(t, ctx, px, upstream, "/blocked", "blocked-updated-bypass", spoofedBypass,
|
|
"updated blocked target bypasses authentication")
|
|
|
|
blockedCIDRs := []string{"0.0.0.0/0", "::/0"}
|
|
request = targetAccessServiceRequest(t, upstream.URL,
|
|
api.ServiceTargetAccessActionBlock,
|
|
api.ServiceTargetAccessActionBypass,
|
|
&api.AccessRestrictions{BlockedCidrs: &blockedCIDRs},
|
|
)
|
|
_, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request)
|
|
require.NoError(t, err, "add connection restriction")
|
|
requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-restricted"), nil,
|
|
http.StatusForbidden, "wait for connection restriction")
|
|
requireProxyAttempt(t, ctx, px, upstream, "/blocked", "blocked-restricted", spoofedBypass,
|
|
http.StatusForbidden, 0, "connection restriction runs before bypass")
|
|
|
|
// Clear the restriction and prove the route is live immediately before
|
|
// deleting it, so a stale 403 cannot make the removal check pass.
|
|
request = targetAccessServiceRequest(t, upstream.URL,
|
|
api.ServiceTargetAccessActionBlock,
|
|
api.ServiceTargetAccessActionBypass,
|
|
&api.AccessRestrictions{},
|
|
)
|
|
_, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request)
|
|
require.NoError(t, err, "clear connection restriction")
|
|
requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-restriction-cleared"), nil,
|
|
http.StatusOK, "wait for cleared connection restriction")
|
|
requireProxyEcho(t, ctx, px, upstream, "/blocked", "blocked-restriction-cleared", spoofedBypass,
|
|
"bypass route is live before removal")
|
|
|
|
require.NoError(t, srv.API().ReverseProxyServices.Delete(ctx, created.Id), "delete service")
|
|
removed = true
|
|
requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-deleted"), nil,
|
|
http.StatusNotFound, "wait for service deletion")
|
|
requireProxyAttempt(t, ctx, px, upstream, "/blocked", "blocked-deleted", nil,
|
|
http.StatusNotFound, 0, "deleted service is removed from the proxy")
|
|
}
|
|
|
|
func targetAccessServiceRequest(
|
|
t *testing.T,
|
|
upstreamURL string,
|
|
publicAction, blockedAction api.ServiceTargetAccessAction,
|
|
restrictions *api.AccessRestrictions,
|
|
) api.ServiceRequest {
|
|
t.Helper()
|
|
|
|
u, err := url.Parse(upstreamURL)
|
|
require.NoError(t, err)
|
|
host, portText, err := net.SplitHostPort(u.Host)
|
|
require.NoError(t, err)
|
|
port, err := strconv.Atoi(portText)
|
|
require.NoError(t, err)
|
|
|
|
rootPath := "/"
|
|
publicPath := "/public"
|
|
nestedPath := "/public/private"
|
|
blockedPath := "/blocked"
|
|
directUpstream := true
|
|
mode := api.ServiceRequestModeHttp
|
|
private := false
|
|
inherit := api.ServiceTargetAccessActionInherit
|
|
|
|
target := func(path *string, action *api.ServiceTargetAccessAction) api.ServiceTarget {
|
|
return api.ServiceTarget{
|
|
AccessAction: action,
|
|
Enabled: true,
|
|
Host: &host,
|
|
Options: &api.ServiceTargetOptions{DirectUpstream: &directUpstream},
|
|
Path: path,
|
|
Port: port,
|
|
Protocol: api.ServiceTargetProtocolHttp,
|
|
TargetId: harness.AgentNetworkCluster,
|
|
TargetType: api.ServiceTargetTargetTypeCluster,
|
|
}
|
|
}
|
|
targets := []api.ServiceTarget{
|
|
target(&rootPath, &inherit),
|
|
target(&publicPath, &publicAction),
|
|
target(&nestedPath, &inherit),
|
|
target(&blockedPath, &blockedAction),
|
|
}
|
|
headerAuths := []api.HeaderAuthConfig{{
|
|
Enabled: true,
|
|
Header: targetAccessHeader,
|
|
Value: targetAccessSecret,
|
|
}}
|
|
|
|
return api.ServiceRequest{
|
|
AccessRestrictions: restrictions,
|
|
Auth: &api.ServiceAuthConfig{HeaderAuths: &headerAuths},
|
|
Domain: targetAccessDomain,
|
|
Enabled: true,
|
|
Mode: &mode,
|
|
Name: "E2E target access control",
|
|
Private: &private,
|
|
Targets: &targets,
|
|
}
|
|
}
|
|
|
|
func targetAccessForwardedHeaders(authValue string) http.Header {
|
|
return http.Header{
|
|
targetAccessHeader: []string{authValue},
|
|
"Authorization": []string{targetAccessApplicationAuth},
|
|
"Cookie": []string{"nb_session=e2e-proxy-session; " + targetAccessApplicationCookie},
|
|
"X-NetBird-User": []string{"spoofed@example.com"},
|
|
"X-NetBird-Groups": []string{"administrators"},
|
|
}
|
|
}
|
|
|
|
func targetAccessPath(targetPrefix, marker string) string {
|
|
return targetPrefix + targetAccessEchoPrefix + marker
|
|
}
|
|
|
|
func requireProxyAttempt(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
px *harness.Proxy,
|
|
upstream *harness.HTTPUpstream,
|
|
targetPrefix, marker string,
|
|
headers http.Header,
|
|
wantStatus, wantUpstreamRequests int,
|
|
description string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
path := targetAccessPath(targetPrefix, marker)
|
|
status, body, requestErr := px.HTTPSGet(ctx, targetAccessDomain, path, headers)
|
|
syncErr := upstream.Synchronize(ctx)
|
|
count, countErr := upstream.RequestCount(ctx, marker)
|
|
if requestErr != nil || syncErr != nil || countErr != nil || status != wantStatus || count != wantUpstreamRequests {
|
|
t.Fatalf("%s: wanted status=%d upstream_requests=%d, got status=%d upstream_requests=%d body=%q request_error=%v sync_error=%v count_error=%v\n=== proxy logs ===\n%s\n=== upstream logs ===\n%s",
|
|
description, wantStatus, wantUpstreamRequests, status, count, body, requestErr, syncErr, countErr,
|
|
px.Logs(context.Background()), upstream.Logs(context.Background()))
|
|
}
|
|
return body
|
|
}
|
|
|
|
func requireProxyEcho(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
px *harness.Proxy,
|
|
upstream *harness.HTTPUpstream,
|
|
targetPrefix, marker string,
|
|
headers http.Header,
|
|
description string,
|
|
) {
|
|
t.Helper()
|
|
|
|
body := requireProxyAttempt(t, ctx, px, upstream, targetPrefix, marker, headers,
|
|
http.StatusOK, 1, description)
|
|
var echo targetAccessEcho
|
|
require.NoError(t, json.Unmarshal([]byte(body), &echo), "decode HTTP upstream observation")
|
|
wantPath := targetAccessEchoPrefix + marker
|
|
assert.Equal(t, "netbird-e2e-http-upstream", echo.Source, "response must come from the test upstream")
|
|
assert.Equal(t, wantPath, echo.Path, "upstream must receive the routed subpath")
|
|
assert.Equal(t, wantPath, echo.RequestURI, "upstream request URI must not contain the matched target prefix")
|
|
assert.Empty(t, echo.ConfiguredAuth, "configured proxy authentication header must be stripped")
|
|
assert.Equal(t, targetAccessApplicationAuth, echo.Authorization, "application authorization must be preserved")
|
|
assert.NotContains(t, echo.Cookie, "nb_session=", "proxy session cookie must be stripped")
|
|
assert.Contains(t, echo.Cookie, targetAccessApplicationCookie, "application cookie must be preserved")
|
|
assert.Empty(t, echo.NetBirdUser, "client-supplied NetBird identity must be stripped")
|
|
assert.Empty(t, echo.NetBirdGroups, "client-supplied NetBird groups must be stripped")
|
|
}
|
|
|
|
func requireProxyStatus(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
px *harness.Proxy,
|
|
path string,
|
|
headers http.Header,
|
|
want int,
|
|
description string,
|
|
) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
var lastCode int
|
|
var lastBody string
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
lastCode, lastBody, lastErr = px.HTTPSGet(ctx, targetAccessDomain, path, headers)
|
|
if lastErr == nil && lastCode == want {
|
|
return
|
|
}
|
|
if !waitBeforeRetry(ctx, time.Second) {
|
|
break
|
|
}
|
|
}
|
|
t.Fatalf("%s: wanted status %d, last status=%d body=%q error=%v\n=== proxy logs ===\n%s",
|
|
description, want, lastCode, lastBody, lastErr, px.Logs(context.Background()))
|
|
}
|
|
|
|
func waitBeforeRetry(ctx context.Context, delay time.Duration) bool {
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-timer.C:
|
|
return true
|
|
}
|
|
}
|