package server import ( "context" "net/url" "os" "runtime" "strings" "testing" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/peer" gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/internal/profilemanager" ) // ctxWithIdentity builds a request context carrying the identity the transport // credentials would have attached. func ctxWithIdentity(id ipcauth.Identity) context.Context { return peer.NewContext(context.Background(), &peer.Peer{ AuthInfo: ipcauth.AuthInfo{ CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, Identity: id, }, }) } // unprivUID is deliberately not this process's own uid. An unprivileged daemon // treats a caller sharing its identity as privileged (rootless containers), and // the test binary would otherwise stand in for both the daemon and the caller. // os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is // unused. var unprivUID = uint32(os.Geteuid() + 1) // The fabricated identities have to be shaped like the platform's: a uid says // nothing on Windows, and a zero uid there would read as root and be privileged. func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) } func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) } func privilegedIdentity() ipcauth.Identity { if runtime.GOOS == "windows" { // LocalSystem, which is what the Windows service account is. return ipcauth.Identity{SID: "S-1-5-18"} } return ipcauth.Identity{UID: 0} } func unprivilegedIdentity() ipcauth.Identity { if runtime.GOOS == "windows" { // A plain user SID: no groups, so no BUILTIN\Administrators, and not // elevated. return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"} } return ipcauth.Identity{UID: unprivUID, GID: unprivUID} } func noIdentityCtx() context.Context { return context.Background() } func boolPtr(v bool) *bool { return &v } func mustURL(t *testing.T, raw string) *url.URL { t.Helper() u, err := url.Parse(raw) if err != nil { t.Fatalf("parse %q: %v", raw, err) } return u } func assertDenied(t *testing.T, err error) { t.Helper() if err == nil { t.Fatal("expected the change to be refused, got nil") } st := gstatus.Convert(err) if st.Code() != codes.PermissionDenied { t.Fatalf("code = %v, want PermissionDenied", st.Code()) } // The refusal must be machine-readable: the CLI and the UI render the // summary and command from the detail rather than parsing the message. var info *errdetails.ErrorInfo for _, d := range st.Details() { if got, ok := d.(*errdetails.ErrorInfo); ok { info = got } } if info == nil { t.Fatal("refusal carries no ErrorInfo detail") } if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain { t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired) } if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" { t.Error("detail carries no summary") } if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" { t.Error("detail carries no command") } } func assertAllowed(t *testing.T, err error) { t.Helper() if err != nil { t.Fatalf("expected the change to be allowed, got %v", err) } } func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { tests := []struct { name string stored *profilemanager.Config change privilegedConfigChange privileged bool wantDeny bool }{ { name: "enabling the ssh server unprivileged is refused", stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, wantDeny: true, }, { name: "enabling the ssh server as root is allowed", stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, privileged: true, }, { name: "restating an already enabled ssh server is not a change", stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, }, { name: "turning the ssh server off is not guarded", stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)}, }, { name: "a profile with no config yet counts as off, so enabling is refused", stored: nil, change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, wantDeny: true, }, { name: "enabling ssh root login unprivileged is refused", stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)}, change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, wantDeny: true, }, { name: "restating ssh root login is not a change", stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, }, { name: "turning ssh root login off is not guarded", stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, change: privilegedConfigChange{enableSSHRoot: boolPtr(false)}, }, { name: "disabling ssh authentication unprivileged is refused", stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)}, change: privilegedConfigChange{disableSSHAuth: boolPtr(true)}, wantDeny: true, }, { name: "re-enabling ssh authentication is not guarded", stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)}, change: privilegedConfigChange{disableSSHAuth: boolPtr(false)}, }, { name: "a request that touches none of the guarded fields is allowed", stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, change: privilegedConfigChange{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := userCtx() if tt.privileged { ctx = rootCtx() } err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) if tt.wantDeny { assertDenied(t, err) return } assertAllowed(t, err) }) } } func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) { sshOn := func(raw string) *profilemanager.Config { return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)} } sshOff := func(raw string) *profilemanager.Config { return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)} } tests := []struct { name string stored *profilemanager.Config requested string privileged bool wantDeny bool }{ { name: "moving the binding while ssh is enabled is refused", stored: sshOn("https://api.netbird.io:443"), requested: "https://attacker.example.com:443", wantDeny: true, }, { name: "moving the binding as root is allowed", stored: sshOn("https://api.netbird.io:443"), requested: "https://selfhosted.example.com:443", privileged: true, }, { name: "the same url restated is not a change", stored: sshOn("https://api.netbird.io:443"), requested: "https://api.netbird.io:443", }, { name: "an equivalent spelling of the same url is not a change", stored: sshOn("https://api.netbird.io:443"), requested: "https://api.netbird.io", }, { name: "an equivalent spelling with an explicit http port is not a change", stored: sshOn("http://mgmt.internal:80"), requested: "http://mgmt.internal", }, { name: "a different port on the same host is a change", stored: sshOn("https://api.netbird.io:443"), requested: "https://api.netbird.io:8443", wantDeny: true, }, { name: "a different scheme on the same host is a change", stored: sshOn("https://mgmt.internal:443"), requested: "http://mgmt.internal:443", wantDeny: true, }, { name: "with ssh disabled the binding is not guarded at all", stored: sshOff("https://api.netbird.io:443"), requested: "https://attacker.example.com:443", }, { name: "an unparseable url fails closed", stored: sshOn("https://api.netbird.io:443"), requested: "ht tp://%zz", wantDeny: true, }, { name: "an empty url leaves the binding alone", stored: sshOn("https://api.netbird.io:443"), requested: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := userCtx() if tt.privileged { ctx = rootCtx() } err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested}) if tt.wantDeny { assertDenied(t, err) return } assertAllowed(t, err) }) } } // A caller the daemon cannot identify must be refused, not trusted: that is the // state on a TCP daemon socket, where no peer credentials exist. func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) { err := requirePrivilegeForConfigChange(noIdentityCtx(), &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, privilegedConfigChange{serverSSHAllowed: boolPtr(true)}) assertDenied(t, err) // The guidance must point at the socket rather than at sudo, since elevating // would not help. st := gstatus.Convert(err) if !strings.Contains(st.Message(), "service install") { t.Errorf("message %q does not tell the operator how to fix the socket", st.Message()) } } func TestRequirePrivilegeForDeregistration(t *testing.T) { tests := []struct { name string cfg *profilemanager.Config privileged bool wantDeny bool }{ { name: "deregistering while ssh is enabled is refused", cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, wantDeny: true, }, { name: "deregistering while ssh is enabled is allowed for root", cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, privileged: true, }, { name: "deregistering with ssh disabled is not guarded", cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, }, { name: "deregistering a profile with no config is not guarded", cfg: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := userCtx() if tt.privileged { ctx = rootCtx() } err := requirePrivilegeForDeregistration(ctx, tt.cfg) if tt.wantDeny { assertDenied(t, err) return } assertAllowed(t, err) }) } } // privilegedTestCtx is the context a handler-level test should use when it is // standing in for a root/administrator caller. Tests that drive the handlers // directly have no transport credentials, and the privileged-change gate refuses // a caller it cannot identify. func privilegedTestCtx() context.Context { return rootCtx() }