mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 02:21:30 +02:00
Restrict debug bundle log path and upload destinations
This commit is contained in:
@@ -7,18 +7,33 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// DebugBundle creates a debug bundle and returns the location.
|
||||
func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
|
||||
func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
|
||||
if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The UI log is opened as whoever asked for this bundle, so a caller only
|
||||
// collects a log it owns (privileged callers excepted). ok is false on a
|
||||
// socket that carries no identity, which skips the UI log.
|
||||
callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx)
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
@@ -68,6 +83,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
SyncResponse: syncResponse,
|
||||
LogPath: s.logFile,
|
||||
UILogPath: s.uiLogPath,
|
||||
UILogOpener: uiLogOpener(callerID, callerIdentified),
|
||||
CPUProfile: cpuProfileData,
|
||||
CapturePath: capturePath,
|
||||
RefreshStatus: refreshStatus,
|
||||
@@ -90,7 +106,12 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
if req.GetUploadURL() == "" {
|
||||
return &proto.DebugBundleResponse{Path: path}, nil
|
||||
}
|
||||
key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
|
||||
// Bound the upload: it runs while s.mutex is held, so an unresponsive
|
||||
// destination must not block other RPCs indefinitely. Matches the mobile
|
||||
// callers' timeout.
|
||||
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), s.config.ManagementURL.String(), path, req.GetUploadInsecure())
|
||||
if err != nil {
|
||||
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
|
||||
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
|
||||
@@ -138,12 +159,34 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
|
||||
// RegisterUILog records the desktop UI's absolute log path so DebugBundle can
|
||||
// collect the GUI log. The daemon runs as root and can't resolve the user's
|
||||
// config dir, so the UI reports it. Last-writer-wins (one UI per socket).
|
||||
func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
|
||||
//
|
||||
// The path arrives over an IPC any local user can reach and is later opened by
|
||||
// a root daemon, so it is constrained to the file name the UI writes and to a
|
||||
// local absolute path. Authorization happens when DebugBundle opens it: the
|
||||
// bundle refuses a file its requester does not own. A caller the daemon cannot
|
||||
// identify cannot register a path at all.
|
||||
func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
|
||||
if _, ok := ipcauth.CallerIdentity(callerCtx); !ok {
|
||||
return nil, gstatus.Error(codes.PermissionDenied,
|
||||
"registering a UI log path requires a control channel that carries the caller's identity")
|
||||
}
|
||||
|
||||
path := filepath.Clean(req.GetPath())
|
||||
if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName)
|
||||
}
|
||||
// filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device
|
||||
// path (\\.\, \\?\); opening one would make the root daemon reach a remote
|
||||
// or device namespace. Require a plain local path.
|
||||
if strings.HasPrefix(path, `\\`) {
|
||||
return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.uiLogPath = req.GetPath()
|
||||
log.Infof("registered UI log path: %s", s.uiLogPath)
|
||||
s.uiLogPath = path
|
||||
log.Infof("registered UI log path %s", s.uiLogPath)
|
||||
|
||||
return &proto.RegisterUILogResponse{}, nil
|
||||
}
|
||||
|
||||
98
client/server/debug_gate.go
Normal file
98
client/server/debug_gate.go
Normal file
@@ -0,0 +1,98 @@
|
||||
//go:build !android && !ios
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// uiLogFileName is the only file name the daemon accepts as a UI log path,
|
||||
// shared with the bundle collector so the two cannot drift. It must also stay in
|
||||
// sync with the name the desktop UI writes (client/ui/uilogpath.go).
|
||||
const uiLogFileName = debug.UILogFile
|
||||
|
||||
// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf
|
||||
// of the caller requesting the bundle: OpenOwnedFile then collects the log only
|
||||
// when that caller owns it (or is privileged). identified is false on a socket
|
||||
// that carries no caller identity, in which case nothing is opened.
|
||||
func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener {
|
||||
return func(path string) (*os.File, error) {
|
||||
if !identified {
|
||||
return nil, fmt.Errorf("bundle requester has no verified identity")
|
||||
}
|
||||
return ipcauth.OpenOwnedFile(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
// requirePrivilegeForUploadURL restricts where the daemon may send a debug
|
||||
// bundle. The bundle holds the daemon's own logs and state, and the daemon
|
||||
// fetches the upload URL itself, so an unrestricted endpoint turns the daemon
|
||||
// into both an exfiltration channel and a request forwarder that reaches
|
||||
// services only it can talk to.
|
||||
//
|
||||
// The upload service NetBird publishes is open to any caller, since that is what
|
||||
// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers
|
||||
// included, requires a privileged caller. Plaintext is refused for everyone: the
|
||||
// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns,
|
||||
// so an http hop is a place to intercept the bundle or the redirect.
|
||||
//
|
||||
// insecure relaxes transport security (http, or an untrusted TLS certificate)
|
||||
// for a self-hosted server. It weakens a root-privileged upload, so it is
|
||||
// refused for an unprivileged caller regardless of the host.
|
||||
func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error {
|
||||
if rawURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err)
|
||||
}
|
||||
|
||||
// --insecure relaxes https to http or an untrusted certificate; it does not
|
||||
// widen the URL to arbitrary schemes, so a host and http/https are required
|
||||
// before the insecure branch takes over.
|
||||
if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host")
|
||||
}
|
||||
|
||||
if insecure {
|
||||
return denyPrivileged(ctx,
|
||||
"uploading a debug bundle without transport security (--upload-bundle-insecure)",
|
||||
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url <url>"))
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" {
|
||||
return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme)
|
||||
}
|
||||
|
||||
if isDefaultUploadService(parsed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return denyPrivileged(ctx,
|
||||
"uploading a debug bundle to an upload service other than the default one",
|
||||
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url <url>"))
|
||||
}
|
||||
|
||||
// isDefaultUploadService reports whether the URL points at the upload service
|
||||
// NetBird runs. Only the host is compared: the service's path may differ between
|
||||
// releases, and the host is what decides who receives the bundle.
|
||||
func isDefaultUploadService(parsed *url.URL) bool {
|
||||
defaultURL, err := url.Parse(types.DefaultBundleURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host)
|
||||
}
|
||||
158
client/server/debug_gate_test.go
Normal file
158
client/server/debug_gate_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
//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},
|
||||
{"traversal onto another file", filepath.Join(t.TempDir(), "..", "..", 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
|
||||
unprivOK bool
|
||||
invalid bool
|
||||
rootAlso bool
|
||||
}{
|
||||
{name: "no upload", url: "", 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},
|
||||
{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)
|
||||
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ type Server struct {
|
||||
// RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle
|
||||
// can collect the GUI log even though the daemon runs as root and can't
|
||||
// resolve the user's config dir. Last-writer-wins (one UI per socket).
|
||||
// DebugBundle opens it on behalf of the bundle requester and refuses a file
|
||||
// that caller does not own, so a local user cannot read another user's log
|
||||
// or a root-only file through it.
|
||||
uiLogPath string
|
||||
|
||||
oauthAuthFlow oauthAuthFlow
|
||||
|
||||
Reference in New Issue
Block a user