mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-08 07:51:28 +02:00
* [client] Bind the cached SSH JWT to the local caller that obtained it Record the identity that obtained the token and return it only to that same identity, comparing the account alone: the group set and the elevation flag describe what a token may do rather than who it belongs to, and the same user may call once elevated and once not. A control channel that carries no caller identity gets a miss on read and stores nothing on write, matching how the other ipcauth consumers fail closed. Clear the entry when the session it speaks for ends: logout, down and profile switch. * [client] Cover the profile-switch path of the SSH JWT cache The cache being correct buys nothing if a handler around it forgets to clear it, and SwitchProfile had no test at all. Point the profile globals at a temp dir holding a single default profile, which is the one ActiveProfileState.FilePath resolves without consulting the current OS user, and call SwitchProfile with no request so neither the switch itself nor the profile-list event is involved. * [client] Report the SSH JWT cache in the no-identity startup warning daemonServerOptions already warns once, at startup, about what a control channel with no caller identity gives up. Name the SSH JWT cache there too, on both the TCP and the no-peer-identity-primitive paths. The per-request logs in cachedJWT and WaitJWTToken drop to Debug: the condition is expected and handled on such a channel, the caller simply re-authenticates, and repeating it on every SSH authentication buried the one message that is actionable. * [client] Stop the local-metrics manager leaking out of the profile test localmetrics.NewManager runs a goroutine until its context is done, and the test handed it context.Background(), so the manager outlived the test and stayed in the test binary for every case that followed. * [client] Keep the cached SSH JWT across a down/up cycle Clearing the cache in cleanupConnection also caught Down, which ends the connection and not the session: the peer stays enrolled, `up` reconnects without going back to the IdP, and the token still belongs to the same NetBird identity. With a long cache TTL that cost the owner a fresh device-code flow for nothing, since the owner binding is what keeps the token away from other local accounts. Clear it on the two paths where the session really ends and the next one may belong to a different NetBird user: profile logout when the profile is the active one, and active-profile logout. SwitchProfile already cleared it on its own. * [client] Resolve the merge conflict in the profile-logout cleanup main extracted the inline profile-logout cleanup into cleanupAfterProfileLogout, which this branch had edited in place to clear the SSH JWT cache. Take main's helper and move the clear inside it. The helper returns early when the profile that was deregistered is not the active one, so the cache is still only cleared when the session that owns the token actually ends. * [client] Do not cache an SSH JWT obtained under a session that ended WaitJWTToken polls the IdP with s.mutex released, and that wait can run for as long as the user takes in the browser. A logout or a profile switch in the meantime clears the cache, but the poll then completed and stored its token anyway, so the entry the next session read belonged to the previous one. Give the cache a generation that clear advances. WaitJWTToken takes the generation before the wait and hands it back to store, which keeps the token only while the generation still matches. The two mutexes are distinct, so this was never a data race and the race detector could not have found it: the window is between two separately locked sections. * [client] Make the profile-switch test switch a profile SwitchProfile with a nil request skips switchProfileIfNeeded, so the test only covered the no-op path and would have passed with profile-transition invalidation broken. Create a second profile and name it in the request, then assert the active profile actually moved before checking the cache. Also correct the comment on the Down test: the logout handlers do call cleanupConnection. What changed is that clearing the cache is no longer one of the things cleanupConnection does. * [client] Take the SSH JWT cache generation when the flow is created WaitJWTToken read the generation after validating the device code, but the flow it belongs to is created earlier, in RequestJWTAuth, and SwitchProfile does not reset s.oauthAuthFlow. A profile switch between the two therefore advanced the generation before it was ever read: the guard compared the new session against itself and let the token through, which is the case it exists to stop. Record the generation on the flow when RequestJWTAuth creates it, and read it from there. The whole span from the request to the IdP answering now counts as one session for the cache. * [client] Correct two test comments the clear-on-Down change invalidated Moving the clear out of cleanupConnection left two comments describing the old behaviour: newTestServer said cleanupConnection clears the cache, and the comment above TestJWTCache_ClearDropsTheEntry listed Down among the callers of clear. Neither is true any more. * [client] Read the SSH JWT cache generation before the IdP round trip RequestJWTAuth read the generation where it stored the flow, which is after RequestAuthInfo has talked to the IdP. A logout or a profile switch during that call advanced the generation first, so the flow recorded the new session's value and the later store was accepted: the window moved rather than closed. Read it with the config, under the same s.mutex section. SwitchProfile holds that mutex across its own clear(), so the config and the generation cannot be torn apart by a switch.
177 lines
5.2 KiB
Go
177 lines
5.2 KiB
Go
package server
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
|
)
|
|
|
|
const testTTL = time.Minute
|
|
|
|
func unixCaller(uid uint32) ipcauth.Identity {
|
|
return ipcauth.Identity{UID: uid, GID: uid}
|
|
}
|
|
|
|
func windowsCaller(sid string) ipcauth.Identity {
|
|
return ipcauth.Identity{SID: sid}
|
|
}
|
|
|
|
func TestJWTCache_ServesTheOwner(t *testing.T) {
|
|
c := newJWTCache()
|
|
owner := unixCaller(1000)
|
|
c.store("token-for-1000", owner, testTTL, c.currentGeneration())
|
|
|
|
got, found := c.get(owner)
|
|
|
|
require.True(t, found, "the identity that stored the token must get it back")
|
|
assert.Equal(t, "token-for-1000", got)
|
|
}
|
|
|
|
// The disclosure this cache guards against: one local account collecting the
|
|
// SSH JWT another account's authentication put in the daemon-wide cache.
|
|
func TestJWTCache_RefusesAnotherLocalUser(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
owner ipcauth.Identity
|
|
caller ipcauth.Identity
|
|
}{
|
|
{"different uid", unixCaller(1000), unixCaller(65534)},
|
|
{"root is not the owner either", unixCaller(1000), unixCaller(0)},
|
|
{"different sid", windowsCaller("S-1-5-21-1-2-3-1001"), windowsCaller("S-1-5-21-1-2-3-1002")},
|
|
{"windows caller against a unix owner", unixCaller(0), windowsCaller("S-1-5-18")},
|
|
{"unix caller against a windows owner", windowsCaller("S-1-5-18"), unixCaller(0)},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := newJWTCache()
|
|
c.store("victim-token", tt.owner, testTTL, c.currentGeneration())
|
|
|
|
got, found := c.get(tt.caller)
|
|
|
|
assert.False(t, found, "a caller that is not the owner must get a miss")
|
|
assert.Empty(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJWTCache_EmptyCacheMatchesNobody(t *testing.T) {
|
|
c := newJWTCache()
|
|
|
|
got, found := c.get(unixCaller(0))
|
|
|
|
assert.False(t, found)
|
|
assert.Empty(t, got)
|
|
}
|
|
|
|
// An entry with no recorded owner must match nobody, root included: an
|
|
// unidentified caller arrives as the zero Identity, which carries uid 0. This
|
|
// pins the nil-owner guard rather than the comparison, so it sets up an entry
|
|
// that exists and then drops its owner.
|
|
func TestJWTCache_UnownedEntryMatchesNobody(t *testing.T) {
|
|
c := newJWTCache()
|
|
c.store("token", unixCaller(1000), testTTL, c.currentGeneration())
|
|
c.owner = nil
|
|
|
|
got, found := c.get(unixCaller(0))
|
|
|
|
assert.False(t, found)
|
|
assert.Empty(t, got)
|
|
}
|
|
|
|
// The same user calling once elevated and once not is still the same user, so
|
|
// hiding their own token from them would be wrong.
|
|
func TestJWTCache_ElevationDoesNotChangeTheOwner(t *testing.T) {
|
|
c := newJWTCache()
|
|
sid := "S-1-5-21-1-2-3-1001"
|
|
owner := windowsCaller(sid)
|
|
owner.Elevated = true
|
|
c.store("token", owner, testTTL, c.currentGeneration())
|
|
|
|
got, found := c.get(windowsCaller(sid))
|
|
|
|
require.True(t, found)
|
|
assert.Equal(t, "token", got)
|
|
}
|
|
|
|
func TestJWTCache_Expiry(t *testing.T) {
|
|
c := newJWTCache()
|
|
owner := unixCaller(1000)
|
|
c.store("token", owner, testTTL, c.currentGeneration())
|
|
c.expiresAt = time.Now().Add(-time.Second)
|
|
|
|
_, found := c.get(owner)
|
|
|
|
assert.False(t, found)
|
|
}
|
|
|
|
// Logout and SwitchProfile call clear — Down deliberately does not: the NetBird
|
|
// session the token speaks for is over, so not even its owner may have it back.
|
|
func TestJWTCache_ClearDropsTheEntry(t *testing.T) {
|
|
c := newJWTCache()
|
|
owner := unixCaller(1000)
|
|
c.store("token", owner, testTTL, c.currentGeneration())
|
|
|
|
c.clear()
|
|
|
|
_, found := c.get(owner)
|
|
assert.False(t, found)
|
|
assert.Nil(t, c.owner, "clear must forget the owner too")
|
|
assert.Nil(t, c.timer, "clear must stop the expiry timer")
|
|
}
|
|
|
|
// WaitJWTToken polls the IdP unlocked, so a logout or a profile switch can
|
|
// clear the cache while a flow is still in the air. The token that flow returns
|
|
// belongs to the session that ended, so it must not land in the cache the new
|
|
// session is using.
|
|
func TestJWTCache_StoreFromAnEndedSessionIsDropped(t *testing.T) {
|
|
c := newJWTCache()
|
|
owner := unixCaller(1000)
|
|
|
|
// The generation a caller takes when its authentication starts.
|
|
generation := c.currentGeneration()
|
|
|
|
c.clear() // logout or profile switch, while the IdP is still being polled
|
|
|
|
stored := c.store("stale-token", owner, testTTL, generation)
|
|
|
|
assert.False(t, stored, "a token from an ended session must not be cached")
|
|
_, found := c.get(owner)
|
|
assert.False(t, found, "the cache must stay empty after the session ended")
|
|
}
|
|
|
|
// The same caller must still be able to store once it re-reads the generation, so
|
|
// the guard does not wedge the cache after any invalidation.
|
|
func TestJWTCache_StoreWorksAgainAfterClear(t *testing.T) {
|
|
c := newJWTCache()
|
|
owner := unixCaller(1000)
|
|
|
|
c.clear()
|
|
|
|
require.True(t, c.store("token", owner, testTTL, c.currentGeneration()))
|
|
|
|
got, found := c.get(owner)
|
|
require.True(t, found)
|
|
assert.Equal(t, "token", got)
|
|
}
|
|
|
|
func TestJWTCache_StoreReplacesThePreviousOwner(t *testing.T) {
|
|
c := newJWTCache()
|
|
first := unixCaller(1000)
|
|
second := unixCaller(1001)
|
|
|
|
c.store("first-token", first, testTTL, c.currentGeneration())
|
|
c.store("second-token", second, testTTL, c.currentGeneration())
|
|
|
|
_, found := c.get(first)
|
|
assert.False(t, found, "the previous owner must not reach the new token")
|
|
|
|
got, found := c.get(second)
|
|
require.True(t, found)
|
|
assert.Equal(t, "second-token", got)
|
|
}
|