[management] Match account ids from the request and service config (#8386)

* match account ids from the request and service config

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* match account ids from the request and service config

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* add tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
This commit is contained in:
dmitri-netbird
2026-10-28 17:38:42 +01:00
committed by GitHub
parent 42418448d8
commit 4ff3267542
6 changed files with 114 additions and 5 deletions
+16 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/auth"
"github.com/netbirdio/netbird/proxy/internal/debug"
"github.com/netbirdio/netbird/proxy/internal/proxy"
nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
"github.com/netbirdio/netbird/proxy/internal/types"
)
@@ -141,7 +142,7 @@ func (m *inboundManager) bringUp(ctx context.Context, accountID types.AccountID,
router := nbtcp.NewRouter(m.logger, accountDialResolver(accountID, client), tlsListener.Addr(), nbtcp.WithPlainHTTP(plainListener.Addr()))
scopedHandler := withTunnelLookup(m.handler, accountTunnelLookup(client))
scopedHandler := withCapturedData(withTunnelLookup(m.handler, accountTunnelLookup(client)), accountID)
// markOverlayOrigin stamps every connection accepted by an inbound
// listener with a context value middlewares can read to skip
@@ -530,6 +531,20 @@ func withTunnelLookup(next http.Handler, lookup auth.TunnelLookupFunc) http.Hand
})
}
func withCapturedData(next http.Handler, accountID types.AccountID) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if d := proxy.CapturedDataFromContext(ctx); d != nil {
d.SetAccountID(accountID)
} else {
d := &proxy.CapturedData{}
d.SetAccountID(accountID)
ctx = proxy.WithCapturedData(ctx, d)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// inboundDebugAdapter adapts *inboundManager to the debug.InboundProvider
// interface so the debug HTTP handler can render per-account inbound
// listener state without importing the proxy package.
+14
View File
@@ -20,6 +20,7 @@ import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/proxy/internal/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
"github.com/netbirdio/netbird/proxy/internal/types"
@@ -279,6 +280,19 @@ func TestWithTunnelLookup_NilLookupIsNoop(t *testing.T) {
assert.True(t, called, "wrapper without lookup must still invoke next")
}
func TestWithTunnelLookup_TracksAccountID(t *testing.T) {
var called bool
inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
called = true
assert.Equal(t, types.AccountID("test-account-id"), proxy.CapturedDataFromContext(r.Context()).GetAccountID())
})
handler := withCapturedData(inner, "test-account-id")
r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
handler.ServeHTTP(httptest.NewRecorder(), r)
assert.True(t, called)
}
// fakeListener satisfies net.Listener for snapshot tests without binding
// a real socket on the netstack.
type fakeListener struct {
+9 -3
View File
@@ -48,10 +48,16 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
// Create a mutable struct to capture data from downstream handlers.
// We pass a pointer in the context - the pointer itself flows down immutably,
// but the struct it points to can be mutated by inner handlers.
capturedData := proxy.NewCapturedData(requestID)
capturedData.SetClientIP(sourceIp)
ctx := proxy.WithCapturedData(r.Context(), capturedData)
ctx := r.Context()
capturedData := proxy.CapturedDataFromContext(r.Context())
if capturedData == nil {
capturedData = &proxy.CapturedData{}
ctx = proxy.WithCapturedData(ctx, capturedData)
}
capturedData.SetRequestID(requestID)
capturedData.SetClientIP(sourceIp)
start := time.Now()
next.ServeHTTP(sw, r.WithContext(ctx))
+6 -1
View File
@@ -135,12 +135,17 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
config, exists := mw.getDomainConfig(host)
mw.logger.Debugf("checking authentication for host: %s, exists: %t", host, exists)
if !exists {
next.ServeHTTP(w, r)
return
}
// enforce account id for requests routed through the embedded client
capturedData := proxy.CapturedDataFromContext(r.Context())
if capturedData != nil && capturedData.GetAccountID() != "" && capturedData.GetAccountID() != config.AccountID {
denyForbidden(w, config)
return
}
// Set account and service IDs in captured data for access logging.
setCapturedIDs(r, config)
+63
View File
@@ -1604,3 +1604,66 @@ func TestStripSessionTokenParam(t *testing.T) {
})
}
}
func TestProtect_RequiresMatchingAccountIds(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
hdr := newHeaderScheme(t, "Authorization", "Bearer token", "Bearer token")
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "test-account-1", "", nil, false, nil))
var backendCalled bool
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
backendCalled = true
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
description string
backendCalled bool
httpStatus int
requestFunc func(*http.Request) *http.Request
}{
{
description: "account id is present and matching",
backendCalled: true,
httpStatus: http.StatusOK,
requestFunc: func(req *http.Request) *http.Request {
d := proxy.CapturedData{}
d.SetAccountID("test-account-1")
return req.WithContext(proxy.WithCapturedData(req.Context(), &d))
},
},
{
description: "account id is present and not matching",
backendCalled: false,
httpStatus: http.StatusForbidden,
requestFunc: func(req *http.Request) *http.Request {
d := proxy.CapturedData{}
d.SetAccountID("test-account-not-matching")
return req.WithContext(proxy.WithCapturedData(req.Context(), &d))
},
},
{
description: "account id not present",
backendCalled: true,
httpStatus: http.StatusOK,
requestFunc: func(req *http.Request) *http.Request { return req },
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
backendCalled = false
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header.Set("Authorization", "Bearer token")
req = tt.requestFunc(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, tt.httpStatus, rec.Code)
assert.Equal(t, tt.backendCalled, backendCalled)
})
}
}
+6
View File
@@ -70,6 +70,12 @@ func NewCapturedData(requestID string) *CapturedData {
return &CapturedData{requestID: requestID}
}
func (c *CapturedData) SetRequestID(requestID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.requestID = requestID
}
// GetRequestID returns the request ID.
func (c *CapturedData) GetRequestID() string {
c.mu.RLock()