Harden the elevation trust checks and narrow what one authorization applies

This commit is contained in:
Viktor Liu
2026-08-05 15:16:22 +02:00
parent 6495ad8687
commit c4d01f0c8e
19 changed files with 724 additions and 348 deletions
+4
View File
@@ -21,6 +21,10 @@ contents:
dst: "/usr/local/bin/netbird-ui"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
# The name the polkit action's icon_name refers to, which the released packages
# install as /usr/share/pixmaps/netbird.png.
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/netbird.png"
- src: "./build/linux/netbird-ui.desktop"
dst: "/usr/share/applications/netbird-ui.desktop"
# Names the polkit action for the elevation prompt the app raises when an
@@ -3,26 +3,17 @@
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!--
Names the polkit action behind the elevation prompt the desktop app raises when
an unprivileged user changes an SSH setting the daemon restricts to root (see
client/ui/services/guarded.go). Without it pkexec falls back to its generic
action, whose dialog offers the user the program path and its flags and asks
whether to run all that as the super user: true, but not something anybody can
make a decision about.
Names the action behind the elevation prompt the desktop app raises for an SSH
setting the daemon restricts to root; without it pkexec's generic dialog offers
the raw command line instead. The argv1 annotation keeps this wording to the
one-shot mode that applies those settings.
The argv1 annotation keeps the action, and therefore this wording, to the
one-shot mode that applies those settings. Anything else run through pkexec
falls back to the generic action, as it should.
auth_admin rather than auth_admin_keep: each of these settings is its own grant
of shell access, so a credential cache would let a second, unasked-for change
ride along on the authorization given the first.
auth_admin rather than auth_admin_keep on purpose: each of these settings is
its own grant of shell access on this host, and a five-minute credential cache
would let a second, unasked-for change ride along on the authorization the user
gave the first.
There are two actions because the binary lands in a different place depending
on how it was packaged: /usr/bin for the released deb and rpm, /usr/local/bin
for a locally built package. pkexec selects the action whose exec.path matches
the program it was asked to run.
exec.path takes no wildcard and the binary's location depends on the package,
hence one action per path.
-->
<policyconfig>
<vendor>NetBird</vendor>
@@ -31,7 +22,7 @@
<action id="io.netbird.settings.apply-privileged">
<description>Change privileged NetBird settings</description>
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
<icon_name>netbird-ui</icon_name>
<icon_name>netbird</icon_name>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
@@ -44,7 +35,7 @@
<action id="io.netbird.settings.apply-privileged-local">
<description>Change privileged NetBird settings</description>
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
<icon_name>netbird-ui</icon_name>
<icon_name>netbird</icon_name>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
@@ -69,6 +69,12 @@ const useSettingsState = () => {
const [guiVersion, setGuiVersion] = useState<string>("—");
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadedRef = useRef<LoadedConfig | null>(null);
// Set when the daemon's config changed while a save was pending, so the read
// that was skipped to protect the pending edit happens once it is through.
// Without it the form keeps values the daemon no longer has and the next save
// submits them, which for a guarded setting means asking the user to authorize
// a change they never made.
const reloadOwed = useRef(false);
useEffect(() => {
loadedRef.current = loaded;
@@ -79,6 +85,7 @@ const useSettingsState = () => {
// update the daemon then rejected.
const reload = useCallback(
async (profileName: string) => {
reloadOwed.current = false;
try {
const data = await SettingsSvc.GetConfig({ profileName, username });
setLoaded({ profileName, data });
@@ -100,7 +107,12 @@ const useSettingsState = () => {
username,
});
if (cancelled) return;
if (saveTimer.current) return;
// A pending edit outranks the daemon's copy until it is saved, so
// the read is owed rather than dropped: see reloadOwed.
if (saveTimer.current) {
reloadOwed.current = true;
return;
}
setLoaded({ profileName: activeProfileId, data });
} catch (e) {
if (cancelled || !showError) return;
@@ -155,7 +167,7 @@ const useSettingsState = () => {
});
// The change needed authorization and the user said no, so the
// optimistic update is wrong. Nothing to report: they know.
if (declined) {
if (declined || reloadOwed.current) {
await reload(profileName);
}
} catch (e) {
+13 -20
View File
@@ -16,11 +16,9 @@ import (
)
// The command line of the one-shot mode this binary runs itself in, elevated, to
// apply a setting the daemon restricts to root/administrator. Named here, next to
// the code that builds the arguments; parsed by runPrivilegedSettings in the main
// package. The setting flags deliberately spell the same words as `netbird up`,
// so what the user is shown as a command and what runs behind the prompt read
// alike.
// apply a setting the daemon restricts to root/administrator. The setting flags
// spell the same words as `netbird up`, so the command a user is shown and what
// runs behind the prompt read alike. Parsed in oneshot.go.
const (
FlagApplyPrivilegedSettings = "apply-privileged-settings"
FlagDaemonAddr = "daemon-addr"
@@ -39,17 +37,14 @@ const (
CodeElevationFailed = "elevation_failed"
)
// elevationTimeout bounds the wait for a prompt and the change behind it, so an
// authentication dialog nobody ever answers does not leave the control it belongs
// to disabled for the rest of the session. Long enough to find a password manager,
// and no shorter than the platforms' own prompt timeouts (Windows gives up on its
// consent dialog after two minutes by itself).
// elevationTimeout bounds the wait for a prompt and the change behind it, so a
// dialog nobody answers does not leave its control disabled for the session. Long
// enough to find a password manager, and no shorter than the platforms' own prompt
// timeouts: Windows gives up on its consent dialog after two minutes by itself.
//
// How much it can actually interrupt differs. On Linux the prompt is a child
// process and is killed with the context; on Windows the wait for it is
// interruptible. On macOS the dialog belongs to Security.framework, which offers no
// way to withdraw the request, so there the timeout only stops us waiting — the
// system's own dialog timeout is what ends it.
// It always ends our waiting, and not always the prompt: Security.framework offers
// no way to withdraw a request, so on macOS the system's own timeout is what closes
// the dialog.
const elevationTimeout = 5 * time.Minute
// elevator raises the platform's privilege prompt and runs the change behind it.
@@ -141,11 +136,9 @@ func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (S
ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
defer cancel()
// Both ends of it: when the prompt went up, and what came of it. These are
// changes that hand out shells on this host, so the log should say who was
// asked and when, and it is also the only account of a prompt that was slow to
// appear or never answered. The daemon records the change itself, against the
// identity it authorized.
// These changes hand out shells on this host, so both ends are logged: when the
// prompt went up, and what came of it. It is also the only account of a prompt
// that was slow to appear or never answered.
log.Infof("asking for privileges to apply %s", guardedSummary(p))
if err := s.elevator.Run(ctx, args...); err != nil {
+63 -4
View File
@@ -23,6 +23,10 @@ import (
// elevation is worth offering at all: see Settings.canElevate.
const testDaemonAddr = "unix:///var/run/netbird.sock"
// storedManagementURL is what the stub daemon already holds, so that a request
// naming a different one is a change: see Settings.guardedChanges.
const storedManagementURL = "https://stored.example.com"
// stubElevator stands in for the platform's prompt: it records what would have run
// and answers with a fixed outcome.
type stubElevator struct {
@@ -38,12 +42,15 @@ func (e *stubElevator) Run(_ context.Context, args ...string) error {
func (e *stubElevator) Available() bool { return e.available }
// stubDaemon implements only the RPC under test. The embedded interface is nil, so
// stubDaemon implements only the RPCs under test. The embedded interface is nil, so
// any other call panics rather than passing quietly.
type stubDaemon struct {
proto.DaemonServiceClient
setConfig func(*proto.SetConfigRequest) error
requests []*proto.SetConfigRequest
// stored is what GetConfig reports, which is what a refused request's guarded
// settings are compared against.
stored *proto.GetConfigResponse
requests []*proto.SetConfigRequest
}
func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) {
@@ -54,6 +61,10 @@ func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _
return &proto.SetConfigResponse{}, nil
}
func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
return d.stored, nil
}
type stubConn struct{ client proto.DaemonServiceClient }
func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil }
@@ -84,12 +95,14 @@ func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevato
}
// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig
// for want of privileges and accepts anything after it.
// for want of privileges and accepts anything after it. Its stored config holds
// another management server and no SSH grants, so a request naming either is a
// change rather than a restatement.
func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
t.Helper()
refusal := privilegeRefusal(t)
daemon := &stubDaemon{}
daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}}
daemon.setConfig = func(*proto.SetConfigRequest) error {
if len(daemon.requests) == 1 {
return refusal
@@ -275,6 +288,52 @@ func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) {
assert.Empty(t, elev.calls, "no prompt where there is none to raise")
}
// One authorization must buy only the change the user made. A settings form
// submits every field it holds, so most of a refused request restates what the
// daemon already has, and elevating those too would apply a guarded setting the
// user never touched — a value gone stale since the form loaded above all.
func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
on, off := true, false
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: storedManagementURL,
ServerSSHAllowed: &off,
EnableSSHRoot: &off,
DisableSSHAuth: &on,
})
require.NoError(t, err)
require.Len(t, elev.calls, 1, "one prompt")
args := elev.calls[0]
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes")
assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL,
"a management URL the daemon already holds")
assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off")
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off")
}
// A request that changes no guarded setting has nothing an elevated run could
// apply, so the refusal must have come from somewhere a prompt cannot reach.
func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) {
elev := &stubElevator{available: true}
s, _ := settingsRefusingOnce(t, elev)
off := false
_, err := s.SetConfig(context.Background(), SetConfigParams{
ProfileName: "default",
ManagementURL: storedManagementURL,
ServerSSHAllowed: &off,
})
var clientErr *ClientError
require.ErrorAs(t, err, &clientErr)
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
assert.Empty(t, elev.calls, "no prompt for a change nobody made")
}
// A refusal with nothing in the request the one-shot could apply: the daemon
// cannot see who is calling, and being root would not help either.
func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) {
+6
View File
@@ -14,6 +14,7 @@ import (
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/elevate"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
@@ -64,6 +65,11 @@ var guardedFields = []guardedField{
usage: "Management server the profile registers with.",
read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" },
write: func(req *proto.SetConfigRequest, value string) error {
// Parsed with the config layer's own parser, so what the elevated run
// accepts cannot drift from what the daemon would store.
if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil {
return err
}
req.ManagementUrl = value
return nil
},
+13 -28
View File
@@ -3,6 +3,7 @@
package services
import (
"flag"
"testing"
"github.com/stretchr/testify/assert"
@@ -123,44 +124,28 @@ func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) {
}
// parseRendered puts the settings through both ends: rendered as the arguments the
// elevated process is given, then parsed as that process parses them.
// elevated process is given, then parsed by a flag set registered from the same
// table, which is what the one-shot itself parses them with. Anything hand-rolled
// here would pin down a parser nothing uses.
func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
t.Helper()
rendered := guardedSettings(p)
require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
values := make([]fieldValue, len(guardedFields))
args := make([]string, 0, len(rendered))
for _, setting := range rendered {
flag, value, found := splitFlag(setting.arg)
require.True(t, found, "rendered %q without a value", setting.arg)
matched := false
for i, field := range guardedFields {
if field.flag != flag {
continue
}
require.NoError(t, values[i].Set(value))
matched = true
}
require.True(t, matched, "rendered %q, which no field claims", setting.arg)
args = append(args, setting.arg)
}
fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError)
values := make([]fieldValue, len(guardedFields))
for i, field := range guardedFields {
fs.Var(&values[i], field.flag, field.usage)
}
require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args)
req, err := privilegedRequest(p.ProfileName, p.Username, values)
require.NoError(t, err)
return req
}
// splitFlag takes "--name=value" apart the way the flag package does.
func splitFlag(arg string) (name, value string, found bool) {
trimmed := arg
for len(trimmed) > 0 && trimmed[0] == '-' {
trimmed = trimmed[1:]
}
for i := 0; i < len(trimmed); i++ {
if trimmed[i] == '=' {
return trimmed[:i], trimmed[i+1:], true
}
}
return trimmed, "", false
}
+40 -7
View File
@@ -252,13 +252,10 @@ func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req
return SaveOutcome{}, s.classifier.classify(refusal)
}
guarded := GuardedSettings{
ProfileName: p.ProfileName,
Username: p.Username,
ManagementURL: p.ManagementURL,
ServerSSHAllowed: p.ServerSSHAllowed,
EnableSSHRoot: p.EnableSSHRoot,
DisableSSHAuth: p.DisableSSHAuth,
guarded, err := s.guardedChanges(ctx, p)
if err != nil {
log.Warnf("cannot tell which guarded settings this request changes: %v", err)
return SaveOutcome{}, s.classifier.classify(refusal)
}
if len(guardedSettings(guarded)) == 0 {
// Refused over something no prompt can settle, such as a control channel
@@ -281,6 +278,33 @@ func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req
return SaveOutcome{}, nil
}
// guardedChanges is the guarded part of a request, reduced to what it actually
// changes.
//
// A settings form submits every field it holds, so a request restates values the
// daemon already has. Carrying those into the elevated run would spend one
// authorization on more than the user asked for, and a value that has gone stale
// since the form was loaded would spend it on something they never asked about.
func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) {
stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username})
if err != nil {
return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err)
}
guarded := GuardedSettings{
ProfileName: p.ProfileName,
Username: p.Username,
ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed),
EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot),
DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth),
}
// An empty URL leaves the setting alone, which is the daemon's rule too.
if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL {
guarded.ManagementURL = p.ManagementURL
}
return guarded, nil
}
// Privilege reports whether this UI process could carry out the changes the
// daemon restricts to root/administrator, whether it can instead ask the
// operating system for the privileges when the user wants one of them, and the
@@ -363,6 +387,15 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
return r, nil
}
// changedFlag returns requested only when it differs from what is stored, so a
// setting the request merely restates is left out of the elevated run.
func changedFlag(requested *bool, stored bool) *bool {
if requested == nil || *requested == stored {
return nil
}
return requested
}
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
managed := cfgResp.GetMDMManagedFields()
if len(managed) == 0 {