mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-30 17:56:11 -04:00
349 lines
11 KiB
Go
349 lines
11 KiB
Go
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() }
|