mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-12 08:29:06 +02:00
DebugBundle uploads when either the upload flag or the upload URL is set, but the privilege gate was told only about the flag. With an MDM policy pinning the destination the gate blanks the caller's URL, so a request carrying a URL and no flag reached the empty-URL branch and found upload false: nothing to weaken, let it through. The daemon then uploaded as root with TLS verification disabled on behalf of an unprivileged caller. A client older than the upload flag sends exactly that shape, a URL and nothing else, so this needs no crafted request to reach. The gate now derives the upload intent from the URL rather than trusting the caller's flag, and DebugBundle computes it once so the gate and the local-only early return cannot drift apart.
177 lines
6.6 KiB
Go
177 lines
6.6 KiB
Go
//go:build !android && !ios
|
|
|
|
package server
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
"github.com/netbirdio/netbird/upload-server/types"
|
|
)
|
|
|
|
func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) {
|
|
s := &Server{}
|
|
|
|
_, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{
|
|
Path: filepath.Join(t.TempDir(), uiLogFileName),
|
|
})
|
|
|
|
if gstatus.Code(err) != codes.PermissionDenied {
|
|
t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err))
|
|
}
|
|
}
|
|
|
|
func TestRegisterUILogRefusesForeignPath(t *testing.T) {
|
|
secret := "/etc/shadow"
|
|
if runtime.GOOS == "windows" {
|
|
secret = `C:\Windows\System32\config\SAM`
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
}{
|
|
{"empty", ""},
|
|
{"relative", filepath.Join("netbird", uiLogFileName)},
|
|
{"another file", secret},
|
|
{"directory of the log", t.TempDir()},
|
|
{"unc path", `\\attacker\share\` + uiLogFileName},
|
|
{"device path", `\\.\C:\` + uiLogFileName},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := &Server{}
|
|
|
|
_, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path})
|
|
|
|
if gstatus.Code(err) != codes.InvalidArgument {
|
|
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
|
|
}
|
|
if s.uiLogPath != "" {
|
|
t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegisterUILogRecordsPath(t *testing.T) {
|
|
s := &Server{}
|
|
path := filepath.Join(t.TempDir(), uiLogFileName)
|
|
|
|
if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
|
|
if s.uiLogPath != path {
|
|
t.Fatalf("path = %q, want %q", s.uiLogPath, path)
|
|
}
|
|
}
|
|
|
|
// The UI log is opened as the bundle requester, so a second local user cannot
|
|
// collect a log they do not own, and an unidentified requester collects nothing.
|
|
func TestUILogOpenerBindsToRequester(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), uiLogFileName)
|
|
if err := os.WriteFile(path, []byte("log line"), 0600); err != nil {
|
|
t.Fatalf("write log: %v", err)
|
|
}
|
|
|
|
// A different unprivileged user than the file's owner: refused.
|
|
if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil {
|
|
t.Fatal("expected a file the requester does not own to be refused")
|
|
}
|
|
|
|
// No verified identity: refused.
|
|
if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil {
|
|
t.Fatal("expected an unidentified requester to be refused")
|
|
}
|
|
|
|
// The requester that owns the file: allowed. The test process created it, so
|
|
// its own identity is the owner (and a privileged runner is exempt anyway).
|
|
owner, err := ipcauth.CurrentProcessIdentity()
|
|
if err != nil {
|
|
t.Fatalf("current identity: %v", err)
|
|
}
|
|
f, err := uiLogOpener(owner, true)(path)
|
|
if err != nil {
|
|
t.Fatalf("expected the owning requester to be allowed, got %v", err)
|
|
}
|
|
_ = f.Close()
|
|
}
|
|
|
|
func TestRequirePrivilegeForUploadURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
insecure bool
|
|
noUpload bool
|
|
mdmPinned bool
|
|
unprivOK bool
|
|
invalid bool
|
|
rootAlso bool
|
|
}{
|
|
{name: "no upload", url: "", unprivOK: true},
|
|
// An empty URL resolves to the destination management published, so
|
|
// relaxing TLS towards it needs the same privilege as naming a host.
|
|
{name: "insecure with no URL", url: "", insecure: true, rootAlso: true},
|
|
// insecure only weakens an upload; a local-only bundle must still pass.
|
|
{name: "insecure with no URL and no upload", url: "", insecure: true, noUpload: true, unprivOK: true},
|
|
{name: "default service", url: types.DefaultBundleURL, unprivOK: true},
|
|
{name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true},
|
|
{name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true},
|
|
{name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true},
|
|
// With MDM pinning the destination the caller's URL is discarded before
|
|
// the upload, so refusing it would only turn a bundle that was going to
|
|
// the pinned host anyway into a denial.
|
|
{name: "custom URL ignored when MDM pins the destination", url: "https://attacker.example/upload-url", mdmPinned: true, unprivOK: true},
|
|
// Transport security is still the caller's to weaken, pinned or not.
|
|
{name: "insecure still gated when MDM pins the destination", url: "https://attacker.example/upload-url", insecure: true, mdmPinned: true, rootAlso: true},
|
|
// A named URL is itself a request to upload: DebugBundle uploads when
|
|
// either the flag or the URL is set. A client older than the upload flag
|
|
// sends exactly this shape, URL and no flag, and with MDM blanking the URL
|
|
// the gate must not read the missing flag as "no upload to weaken".
|
|
{name: "insecure, URL set, no upload flag, MDM pinned", url: "https://x.example/upload-url", insecure: true, noUpload: true, mdmPinned: true, rootAlso: true},
|
|
{name: "insecure, URL set, no upload flag", url: "https://x.example/upload-url", insecure: true, noUpload: true, rootAlso: true},
|
|
{name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true},
|
|
{name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true},
|
|
{name: "unsupported scheme", url: "file:///etc/shadow", invalid: true},
|
|
// insecure relaxes transport security; privileged only, whatever the host.
|
|
{name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true},
|
|
{name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true},
|
|
{name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true},
|
|
// --insecure must not widen the URL to non-http(s) schemes or a hostless URL.
|
|
{name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true},
|
|
{name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure, !tc.noUpload, tc.mdmPinned)
|
|
|
|
switch {
|
|
case tc.invalid:
|
|
if gstatus.Code(err) != codes.InvalidArgument {
|
|
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
|
|
}
|
|
return
|
|
case tc.unprivOK:
|
|
assertAllowed(t, err)
|
|
return
|
|
default:
|
|
assertDenied(t, err)
|
|
}
|
|
|
|
if tc.rootAlso {
|
|
assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure, !tc.noUpload, tc.mdmPinned))
|
|
}
|
|
})
|
|
}
|
|
}
|