Ctrl K
ComunidadGuías…

proxy/target_access_control_integration_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package proxy

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/base64"
	"net"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strconv"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
	"github.com/netbirdio/netbird/management/server/store"
	"github.com/netbirdio/netbird/shared/hash/argon2id"
	"github.com/netbirdio/netbird/shared/management/proto"
)

func targetAccessService(t *testing.T, upstreamURL string) *service.Service {
	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.ParseUint(portText, 10, 16)
	require.NoError(t, err)

	headerHash, err := argon2id.Hash(targetAccessSecret)
	require.NoError(t, err)
	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)

	newTarget := func(path string, action service.TargetAccessAction) *service.Target {
		return &service.Target{
			Path:         strPtr(path),
			Host:         host,
			Port:         uint16(port),
			Protocol:     u.Scheme,
			TargetId:     "target-access-upstream",
			TargetType:   service.TargetTypeCluster,
			Enabled:      true,
			AccessAction: action,
			Options: service.TargetOptions{
				DirectUpstream: true,
			},
		}
	}

	return &service.Service{
		ID:                "target-access-service",
		AccountID:         targetAccessAccountID,
		Name:              "Target Access Control",
		Domain:            targetAccessDomain,
		ProxyCluster:      targetAccessCluster,
		Enabled:           true,
		SessionPrivateKey: base64.StdEncoding.EncodeToString(privateKey),
		SessionPublicKey:  base64.StdEncoding.EncodeToString(publicKey),
		Targets: []*service.Target{
			newTarget("/", service.TargetAccessActionInherit),
			newTarget("/public", service.TargetAccessActionBypass),
			newTarget("/blocked", service.TargetAccessActionBlock),
		},
		Auth: service.AuthConfig{HeaderAuths: []*service.HeaderAuthConfig{{
			Enabled: true,
			Header:  targetAccessHeader,
			Value:   headerHash,
		}}},
		Restrictions: service.AccessRestrictions{
			BlockedCIDRs: []string{"203.0.113.0/24"},
		},
	}
}

func privateServiceCapabilities() *proto.ProxyCapabilities {
	supported := true
	return &proto.ProxyCapabilities{
		SupportsPrivateService: &supported,
	}
}

func targetAccessMappingStream(t *testing.T, setup *integrationTestSetup, proxyID string, caps *proto.ProxyCapabilities) proto.ProxyService_GetMappingUpdateClient {
	t.Helper()

	conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	require.NoError(t, err)
	t.Cleanup(func() { _ = conn.Close() })

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	t.Cleanup(cancel)
	stream, err := proto.NewProxyServiceClient(conn).GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
		ProxyId:      proxyID,
		Version:      "target-access-test",
		Address:      targetAccessCluster,
		Capabilities: caps,
	})
	require.NoError(t, err)
	return stream
}

func receiveTargetAccessSnapshot(t *testing.T, stream proto.ProxyService_GetMappingUpdateClient) map[string]*proto.ProxyMapping {
	t.Helper()

	mappings := make(map[string]*proto.ProxyMapping)
	for {
		msg, err := stream.Recv()
		require.NoError(t, err)
		for _, mapping := range msg.GetMapping() {
			mappings[mapping.GetId()] = mapping
		}
		if msg.GetInitialSyncComplete() {
			return mappings
		}
	}
}

func receiveTargetAccessUpdate(t *testing.T, stream proto.ProxyService_GetMappingUpdateClient, serviceID string) *proto.ProxyMapping {
	t.Helper()

	for {
		msg, err := stream.Recv()
		require.NoError(t, err)
		for _, mapping := range msg.GetMapping() {
			if mapping.GetId() == serviceID {
				return mapping
			}
		}
	}
}

func TestIntegration_TargetAccessControl_ManagementToUpstream(t *testing.T) {
	setup := setupIntegrationTest(t)
	t.Cleanup(setup.cleanup)

	upstreamRecorder := &targetAccessUpstream{}
	upstream := httptest.NewServer(upstreamRecorder)
	t.Cleanup(upstream.Close)

	svc := targetAccessService(t, upstream.URL)
	require.NoError(t, setup.store.CreateService(t.Context(), svc))

	stream := targetAccessMappingStream(t, setup, "target-access-proxy", nil)
	snapshot := receiveTargetAccessSnapshot(t, stream)
	mapping := snapshot[svc.ID]
	require.NotNil(t, mapping, "proxy must receive the target-access service")

	actions := make(map[string]proto.TargetAccessAction)
	for _, pathMapping := range mapping.GetPath() {
		actions[pathMapping.GetPath()] = pathMapping.GetAccessAction()
	}
	assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, actions["/"])
	assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, actions["/public"])
	assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, actions["/blocked"])

	runtime, handler := newTargetAccessRuntime(t)
	applyTargetAccessMapping(t, runtime, mapping)

	before := len(upstreamRecorder.snapshot())
	res := targetAccessRequestTo(handler, "/private", "198.51.100.10:12000", nil)
	assert.Equal(t, http.StatusUnauthorized, res.Code, "inherit must retain service authentication")
	assert.Len(t, upstreamRecorder.snapshot(), before, "unauthenticated inherited request must not reach upstream")

	validHeader := http.Header{targetAccessHeader: []string{targetAccessSecret}}
	res = targetAccessRequestTo(handler, "/private", "198.51.100.10:12000", validHeader)
	assert.Equal(t, http.StatusOK, res.Code, "valid service authentication must pass an inherited target")
	inheritedRequests := upstreamRecorder.snapshot()
	require.Len(t, inheritedRequests, before+1)
	assert.Empty(t, inheritedRequests[len(inheritedRequests)-1].headers.Get(targetAccessHeader),
		"proxy authentication credential must not be forwarded upstream")

	bypassHeaders := http.Header{
		targetAccessHeader: []string{"spoofed-auth"},
		"X-NetBird-User":   []string{"spoofed@example.com"},
		"X-NetBird-Groups": []string{"administrators"},
	}
	res = targetAccessRequestTo(handler, "/public/ready", "198.51.100.10:12000", bypassHeaders)
	assert.Equal(t, http.StatusOK, res.Code, "bypass target must reach upstream without authenticating")
	bypassRequests := upstreamRecorder.snapshot()
	require.Len(t, bypassRequests, before+2)
	bypassed := bypassRequests[len(bypassRequests)-1]
	assert.Equal(t, "/ready", bypassed.path, "routing must retain the existing literal-prefix rewrite")
	assert.Empty(t, bypassed.headers.Get(targetAccessHeader), "configured auth header must be stripped on bypass")
	assert.Empty(t, bypassed.headers.Get("X-NetBird-User"), "bypass must not permit spoofed identity")
	assert.Empty(t, bypassed.headers.Get("X-NetBird-Groups"), "bypass must not permit spoofed groups")

	before = len(bypassRequests)
	res = targetAccessRequestTo(handler, "/blocked/secret", "198.51.100.10:12000", validHeader)
	assert.Equal(t, http.StatusForbidden, res.Code, "block must win even when service authentication is valid")
	assert.Len(t, upstreamRecorder.snapshot(), before, "blocked request must not reach upstream")

	res = targetAccessRequestTo(handler, "/public/ready", "203.0.113.8:12000", nil)
	assert.Equal(t, http.StatusForbidden, res.Code, "connection restrictions must run before bypass")
	assert.Len(t, upstreamRecorder.snapshot(), before, "restricted bypass request must not reach upstream")

	for _, target := range svc.Targets {
		switch *target.Path {
		case "/public":
			target.AccessAction = service.TargetAccessActionBlock
		case "/blocked":
			target.AccessAction = service.TargetAccessActionBypass
		}
	}
	require.NoError(t, setup.store.UpdateService(t.Context(), svc))
	persisted, err := setup.store.GetServiceByID(t.Context(), store.LockingStrengthNone, targetAccessAccountID, svc.ID)
	require.NoError(t, err)
	update := persisted.ToProtoMapping(service.Update, "", nbproxy.OIDCValidationConfig{})
	setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{Mapping: []*proto.ProxyMapping{update}})
	receivedUpdate := receiveTargetAccessUpdate(t, stream, svc.ID)
	applyTargetAccessMapping(t, runtime, receivedUpdate)

	res = targetAccessRequestTo(handler, "/public/ready", "198.51.100.10:12000", validHeader)
	assert.Equal(t, http.StatusForbidden, res.Code, "modified action must replace bypass with block")
	assert.Len(t, upstreamRecorder.snapshot(), before, "newly blocked request must not reach upstream")

	res = targetAccessRequestTo(handler, "/blocked/ready", "198.51.100.10:12000", nil)
	assert.Equal(t, http.StatusOK, res.Code, "modified action must replace block with bypass")
	assert.Len(t, upstreamRecorder.snapshot(), before+1, "newly bypassed request must reach upstream")

	require.NoError(t, setup.store.DeleteService(t.Context(), targetAccessAccountID, svc.ID))
	removed := persisted.ToProtoMapping(service.Delete, "", nbproxy.OIDCValidationConfig{})
	setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{Mapping: []*proto.ProxyMapping{removed}})
	receivedRemoval := receiveTargetAccessUpdate(t, stream, svc.ID)
	runtime.removeMapping(t.Context(), receivedRemoval)

	res = targetAccessRequestTo(handler, "/blocked/ready", "198.51.100.10:12000", nil)
	assert.Equal(t, http.StatusNotFound, res.Code, "removed mapping must no longer route")
	assert.Len(t, upstreamRecorder.snapshot(), before+1, "request after removal must not reach upstream")
}

func TestIntegration_TargetAccessControl_PrivateServiceRejectsBypass(t *testing.T) {
	setup := setupIntegrationTest(t)
	t.Cleanup(setup.cleanup)

	upstreamRecorder := &targetAccessUpstream{}
	upstream := httptest.NewServer(upstreamRecorder)
	t.Cleanup(upstream.Close)

	svc := targetAccessService(t, upstream.URL)
	svc.ID = "target-access-private"
	svc.Domain = "target-access-private.test.proxy.io"
	svc.Private = true
	svc.Targets = svc.Targets[1:2]
	require.NoError(t, setup.store.CreateService(t.Context(), svc))

	stream := targetAccessMappingStream(t, setup, "target-access-private", privateServiceCapabilities())
	mapping := receiveTargetAccessSnapshot(t, stream)[svc.ID]
	require.NotNil(t, mapping, "proxy supporting private services must receive private target-access mapping")

	runtime, handler := newTargetAccessRuntime(t)
	err := runtime.updateMapping(t.Context(), mapping)
	require.Error(t, err, "private service with a bypass target must be rejected")

	req := httptest.NewRequest(http.MethodGet, "http://"+svc.Domain+"/public/ready", nil)
	req.Host = svc.Domain
	req.RemoteAddr = "100.64.0.20:12000"
	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, req)

	assert.Equal(t, http.StatusNotFound, recorder.Code, "rejected private mapping must not be published")
	assert.Empty(t, upstreamRecorder.snapshot(), "rejected private mapping must not reach upstream")
}