diff --git a/landlock/abi_versions.go b/landlock/abi_versions.go index cfa02b2..4f67979 100644 --- a/landlock/abi_versions.go +++ b/landlock/abi_versions.go @@ -3,8 +3,9 @@ package landlock import ll "github.com/landlock-lsm/go-landlock/landlock/syscall" type abiInfo struct { - version int - supportedAccessFS AccessFSSet + version int + supportedAccessFS AccessFSSet + supportedAccessNet AccessNetSet } var abiInfos = []abiInfo{ @@ -24,10 +25,18 @@ var abiInfos = []abiInfo{ version: 3, supportedAccessFS: (1 << 15) - 1, }, + { + version: 4, + supportedAccessFS: (1 << 15) - 1, + supportedAccessNet: (1 << 2) - 1, + }, } func (a abiInfo) asConfig() Config { - return Config{handledAccessFS: a.supportedAccessFS} + return Config{ + handledAccessFS: a.supportedAccessFS, + handledAccessNet: a.supportedAccessNet, + } } // getSupportedABIVersion returns the kernel-supported ABI version. diff --git a/landlock/abi_versions_test.go b/landlock/abi_versions_test.go index 66f0c80..5399070 100644 --- a/landlock/abi_versions_test.go +++ b/landlock/abi_versions_test.go @@ -13,7 +13,7 @@ func TestAbiVersionsIncrementing(t *testing.T) { } func TestSupportedAccessFS(t *testing.T) { - got := abiInfos[3].supportedAccessFS + got := abiInfos[4].supportedAccessFS want := supportedAccessFS if got != want { diff --git a/landlock/accessnet.go b/landlock/accessnet.go new file mode 100644 index 0000000..c2a4faf --- /dev/null +++ b/landlock/accessnet.go @@ -0,0 +1,35 @@ +package landlock + +// AccessNetSet is a set of Landlockable network access rights. +type AccessNetSet uint64 + +var accessNetNames = []string{ + "bind_tcp", + "connect_tcp", +} + +var supportedAccessNet = AccessNetSet((1 << len(accessNetNames)) - 1) + +func (a AccessNetSet) String() string { + return accessSetString(uint64(a), accessNetNames) +} + +func (a AccessNetSet) isSubset(b AccessNetSet) bool { + return a&b == a +} + +func (a AccessNetSet) intersect(b AccessNetSet) AccessNetSet { + return a & b +} + +func (a AccessNetSet) union(b AccessNetSet) AccessNetSet { + return a | b +} + +func (a AccessNetSet) isEmpty() bool { + return a == 0 +} + +func (a AccessNetSet) valid() bool { + return a.isSubset(supportedAccessNet) +} diff --git a/landlock/config.go b/landlock/config.go index ce2a1b9..6a321e8 100644 --- a/landlock/config.go +++ b/landlock/config.go @@ -35,6 +35,8 @@ var ( V2 = abiInfos[2].asConfig() // Landlock V3 support (V2 + file truncation) V3 = abiInfos[3].asConfig() + // Landlock V4 support (V3 + networking) + V4 = abiInfos[4].asConfig() ) // v0 denotes "no Landlock support". Only used internally. @@ -44,8 +46,9 @@ var v0 = Config{} // landlockable operations to be restricted and the constraints on it // (e.g. best effort mode). type Config struct { - handledAccessFS AccessFSSet - bestEffort bool + handledAccessFS AccessFSSet + handledAccessNet AccessNetSet + bestEffort bool } // NewConfig creates a new Landlock configuration with the given parameters. @@ -64,15 +67,24 @@ func NewConfig(args ...interface{}) (*Config, error) { // invalid Config values. var c Config for _, arg := range args { - if afs, ok := arg.(AccessFSSet); ok { + switch arg := arg.(type) { + case AccessFSSet: if !c.handledAccessFS.isEmpty() { return nil, errors.New("only one AccessFSSet may be provided") } - if !afs.valid() { + if !arg.valid() { return nil, errors.New("unsupported AccessFSSet value; upgrade go-landlock?") } - c.handledAccessFS = afs - } else { + c.handledAccessFS = arg + case AccessNetSet: + if !c.handledAccessNet.isEmpty() { + return nil, errors.New("only one AccessNetSet may be provided") + } + if !arg.valid() { + return nil, errors.New("unsupported AccessNetSet value; upgrade go-landlock?") + } + c.handledAccessNet = arg + default: return nil, fmt.Errorf("unknown argument %v; only AccessFSSet-type argument is supported", arg) } } @@ -103,6 +115,11 @@ func (c Config) String() string { fsDesc = "all" } + var netDesc = c.handledAccessNet.String() + if abi.supportedAccessNet == c.handledAccessNet && c.handledAccessNet != 0 { + fsDesc = "all" + } + var bestEffort = "" if c.bestEffort { bestEffort = " (best effort)" @@ -115,7 +132,7 @@ func (c Config) String() string { version = fmt.Sprintf("V%v", abi.version) } - return fmt.Sprintf("{Landlock %v; FS: %v%v}", version, fsDesc, bestEffort) + return fmt.Sprintf("{Landlock %v; FS: %v; Net: %v%v}", version, fsDesc, netDesc, bestEffort) } // BestEffort returns a config that will opportunistically enforce @@ -222,6 +239,12 @@ func (c Config) BestEffort() Config { // // [Kernel Documentation about Access Rights]: https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#access-rights func (c Config) RestrictPaths(rules ...Rule) error { + c.handledAccessNet = 0 // clear out everything but file system access + return restrict(c, rules...) +} + +func (c Config) RestrictNet(rules ...Rule) error { + c.handledAccessFS = 0 // clear out everything but network access return restrict(c, rules...) } @@ -231,7 +254,8 @@ type PathOpt = Rule // compatibleWith is true if c is compatible to work at the given Landlock ABI level. func (c Config) compatibleWithABI(abi abiInfo) bool { - return c.handledAccessFS.isSubset(abi.supportedAccessFS) + return (c.handledAccessFS.isSubset(abi.supportedAccessFS) && + c.handledAccessNet.isSubset(abi.supportedAccessNet)) } // restrictTo returns a config that is a subset of c and which is compatible with the given ABI. diff --git a/landlock/config_test.go b/landlock/config_test.go index 8009de8..e80b0b2 100644 --- a/landlock/config_test.go +++ b/landlock/config_test.go @@ -1,7 +1,6 @@ package landlock import ( - "fmt" "testing" ll "github.com/landlock-lsm/go-landlock/landlock/syscall" @@ -13,24 +12,28 @@ func TestConfigString(t *testing.T) { want string }{ { - cfg: Config{handledAccessFS: 0}, - want: fmt.Sprintf("{Landlock V0; FS: %v}", AccessFSSet(0)), + cfg: Config{handledAccessFS: 0, handledAccessNet: 0}, + want: "{Landlock V0; FS: ∅; Net: ∅}", }, { cfg: Config{handledAccessFS: ll.AccessFSWriteFile}, - want: "{Landlock V1; FS: {write_file}}", + want: "{Landlock V1; FS: {write_file}; Net: ∅}", + }, + { + cfg: Config{handledAccessNet: ll.AccessNetBindTCP}, + want: "{Landlock V4; FS: ∅; Net: {bind_tcp}}", }, { cfg: V1, - want: "{Landlock V1; FS: all}", + want: "{Landlock V1; FS: all; Net: ∅}", }, { cfg: V1.BestEffort(), - want: "{Landlock V1; FS: all (best effort)}", + want: "{Landlock V1; FS: all; Net: ∅ (best effort)}", }, { cfg: Config{handledAccessFS: 1 << 63}, - want: "{Landlock V???; FS: {1<<63}}", + want: "{Landlock V???; FS: {1<<63}; Net: ∅}", }, } { got := tc.cfg.String() diff --git a/landlock/net_opt.go b/landlock/net_opt.go new file mode 100644 index 0000000..1d4859f --- /dev/null +++ b/landlock/net_opt.go @@ -0,0 +1,55 @@ +package landlock + +import ( + "fmt" + + ll "github.com/landlock-lsm/go-landlock/landlock/syscall" +) + +type NetRule struct { + access AccessNetSet + port uint16 +} + +func DialTCP(port uint16) NetRule { + return NetRule{ + access: ll.AccessNetConnectTCP, + port: port, + } +} + +// BindTCP is a [Rule] which grants the right to bind a socket to a +// given TCP port. +// +// In Go, the bind(2) operation is usually run as part of +// net.Listen(). +func BindTCP(port uint16) NetRule { + return NetRule{ + access: ll.AccessNetBindTCP, + port: port, + } +} + +func (n NetRule) String() string { + return fmt.Sprintf("ALLOW %v on TCP port %v", n.access, n.port) +} + +func (n NetRule) compatibleWithConfig(c Config) bool { + return n.access.isSubset(c.handledAccessNet) +} + +func (n NetRule) addToRuleset(rulesetFD int, c Config) error { + flags := 0 + attr := &ll.NetServiceAttr{ + AllowedAccess: uint64(n.access), + Port: n.port, + } + return ll.LandlockAddNetServiceRule(rulesetFD, attr, flags) +} + +func (n NetRule) downgrade(c Config) (out Rule, ok bool) { + return NetRule{ + access: n.access.intersect(c.handledAccessNet), + port: n.port, + }, true +} diff --git a/landlock/opt.go b/landlock/opt.go index df90844..3b8cb18 100644 --- a/landlock/opt.go +++ b/landlock/opt.go @@ -12,7 +12,8 @@ type Rule interface { // // It establishes that: // - // - rule.accessFS ⊆ handledAccessFS + // - rule.accessFS ⊆ handledAccessFS for PathRules + // - rule.accessNet ⊆ handledAccessNet for NetRules // // If the rule is unsupportable under the given Config at // all, ok is false. This happens when c represents a Landlock diff --git a/landlock/restrict.go b/landlock/restrict.go index e4f1cdb..04e5789 100644 --- a/landlock/restrict.go +++ b/landlock/restrict.go @@ -50,12 +50,13 @@ func restrict(c Config, rules ...Rule) error { // always implicit, even in Landlock V1. So enabling Landlock // on a Landlock V1 kernel without any handled access rights // will still forbid linking files between directories. - if c.handledAccessFS.isEmpty() { + if c.handledAccessFS.isEmpty() && c.handledAccessNet.isEmpty() { return nil // Success: Nothing to restrict. } rulesetAttr := ll.RulesetAttr{ - HandledAccessFS: uint64(c.handledAccessFS), + HandledAccessFS: uint64(c.handledAccessFS), + HandledAccessNet: uint64(c.handledAccessNet), } fd, err := ll.LandlockCreateRuleset(&rulesetAttr, 0) if err != nil { diff --git a/landlock/restrict_downgrade_test.go b/landlock/restrict_downgrade_test.go index 9445a7e..e545931 100644 --- a/landlock/restrict_downgrade_test.go +++ b/landlock/restrict_downgrade_test.go @@ -8,7 +8,7 @@ import ( ll "github.com/landlock-lsm/go-landlock/landlock/syscall" ) -func TestDowngrade(t *testing.T) { +func TestDowngradeAccessFS(t *testing.T) { for _, tc := range []struct { Name string diff --git a/landlock/restrict_test.go b/landlock/restrict_test.go index 36716b2..f70b8ca 100644 --- a/landlock/restrict_test.go +++ b/landlock/restrict_test.go @@ -5,14 +5,18 @@ package landlock_test import ( "bytes" "errors" + "fmt" + "net" "os" "path/filepath" "strconv" "strings" + "sync" "syscall" "testing" "github.com/landlock-lsm/go-landlock/landlock" + ll "github.com/landlock-lsm/go-landlock/landlock/syscall" "golang.org/x/sys/unix" ) @@ -259,13 +263,6 @@ func TestRestrictPaths(t *testing.T) { } } -func errEqual(got, want error) bool { - if got == nil && want == nil { - return true - } - return errors.Is(got, want) -} - func openForRead(path string) error { f, err := os.Open(path) if err != nil { @@ -284,6 +281,183 @@ func openForWrite(path string) error { return nil } +func TestRestrictNet(t *testing.T) { + const ( + cPort = 4242 + bPort = 4343 + ) + + for _, tt := range []struct { + Name string + EnableLandlock func() error + RequiredABI int + WantConnectErr error + WantBindErr error + }{ + { + Name: "ABITooOld", + RequiredABI: 3, + EnableLandlock: func() error { + return landlock.V3.RestrictNet() + }, + WantConnectErr: nil, + WantBindErr: nil, + }, + { + Name: "ABITooOldWithDowngrade", + RequiredABI: 3, + EnableLandlock: func() error { + return landlock.V3.BestEffort().RestrictNet() + }, + WantConnectErr: nil, + WantBindErr: nil, + }, + { + Name: "RestrictingPathsShouldNotBreakNetworking", + RequiredABI: 1, + EnableLandlock: func() error { + return landlock.V4.BestEffort().RestrictPaths( + landlock.ROFiles("/etc/hosts"), + ) + }, + WantConnectErr: nil, + WantBindErr: nil, + }, + { + Name: "RestrictingBindButConnectShouldWork", + RequiredABI: 4, + EnableLandlock: func() error { + return landlock.MustConfig( + landlock.AccessNetSet(ll.AccessNetBindTCP), + ).RestrictNet() + }, + WantConnectErr: nil, + WantBindErr: syscall.EACCES, + }, + { + Name: "RestrictingConnectButBindShouldWork", + RequiredABI: 4, + EnableLandlock: func() error { + return landlock.MustConfig( + landlock.AccessNetSet(ll.AccessNetConnectTCP), + ).RestrictNet() + }, + WantConnectErr: syscall.EACCES, + WantBindErr: nil, + }, + { + Name: "PermitTheConnectPort", + RequiredABI: 4, + EnableLandlock: func() error { + return landlock.V4.RestrictNet(landlock.DialTCP(cPort)) + }, + WantConnectErr: nil, + WantBindErr: syscall.EACCES, + }, + { + Name: "PermitTheBindPort", + RequiredABI: 4, + EnableLandlock: func() error { + return landlock.V4.RestrictNet(landlock.BindTCP(bPort)) + }, + WantConnectErr: syscall.EACCES, + WantBindErr: nil, + }, + { + Name: "PermitBothPorts", + RequiredABI: 4, + EnableLandlock: func() error { + return landlock.V4.RestrictNet( + landlock.BindTCP(bPort), + landlock.DialTCP(cPort), + ) + }, + WantConnectErr: nil, + WantBindErr: nil, + }, + { + Name: "PermitTheWrongPorts", + RequiredABI: 4, + EnableLandlock: func() error { + return landlock.V4.RestrictNet( + landlock.BindTCP(bPort+1), + landlock.DialTCP(cPort+1), + ) + }, + WantConnectErr: syscall.EACCES, + WantBindErr: syscall.EACCES, + }, + } { + t.Run(tt.Name, func(t *testing.T) { + RunInSubprocess(t, func() { + RequireLandlockABI(t, tt.RequiredABI) + + // Set up a service that we can dial for the test. + runBackgroundService(t, "tcp", fmt.Sprintf("localhost:%v", cPort)) + + err := tt.EnableLandlock() + if err != nil { + t.Fatalf("Enabling Landlock: %v", err) + } + + if err := tryDial(cPort); !errEqual(err, tt.WantConnectErr) { + t.Errorf("net.Dial(tcp, localhost:%v) = «%v»; want «%v»", cPort, err, tt.WantConnectErr) + } + if err := tryListen(bPort); !errEqual(err, tt.WantBindErr) { + t.Errorf("net.Listen(tcp, localhost:%v) = «%v»; want «%v»", bPort, err, tt.WantBindErr) + } + }) + }) + } +} + +func runBackgroundService(t *testing.T, network, addr string) { + l, err := net.Listen(network, addr) + if err != nil { + t.Fatalf("net.Listen: Failed to set up local service to connect to: %v", err) + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + c, err := l.Accept() + if err != nil { + // Return on error (e.g. if l gets closed asynchronously) + return + } + c.Close() + } + }() + t.Cleanup(func() { + l.Close() + wg.Wait() + }) +} + +func tryDial(port int) error { + conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%v", port)) + if err == nil { + conn.Close() + } + return err +} + +func tryListen(port int) error { + conn, err := net.Listen("tcp", fmt.Sprintf("localhost:%v", port)) + if err == nil { + conn.Close() + } + return err +} + +func errEqual(got, want error) bool { + if got == nil && want == nil { + return true + } + return errors.Is(got, want) +} + func OSRelease(t testing.TB) (major, minor, patch int) { t.Helper() diff --git a/landlock/syscall/landlock.go b/landlock/syscall/landlock.go index 7976d7b..48bc657 100644 --- a/landlock/syscall/landlock.go +++ b/landlock/syscall/landlock.go @@ -14,10 +14,10 @@ // https://www.kernel.org/doc/html/latest/userspace-api/landlock.html. package syscall -// Landlock file system access rights, for use in "access" bit fields. +// Landlock file system access rights. // // Please see the full documentation at -// https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#access-rights. +// https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#filesystem-flags. const ( AccessFSExecute = 1 << iota AccessFSWriteFile @@ -36,17 +36,28 @@ const ( AccessFSTruncate ) +// Landlock network access rights. +// +// Please see the full documentation at +// https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#network-flags. +const ( + // TODO: Use these from sys/unix when available. + AccessNetBindTCP = 1 << 0 + AccessNetConnectTCP = 1 << 1 +) + // RulesetAttr is the Landlock ruleset definition. // // Argument of LandlockCreateRuleset(). This structure can grow in future versions of Landlock. // // C version is in usr/include/linux/landlock.h type RulesetAttr struct { - HandledAccessFS uint64 + HandledAccessFS uint64 + HandledAccessNet uint64 } // The size of the RulesetAttr struct in bytes. -const rulesetAttrSize = 8 +const rulesetAttrSize = 16 // PathBeneathAttr references a file hierarchy and defines the desired // extent to which it should be usable when the rule is enforced. @@ -60,3 +71,9 @@ type PathBeneathAttr struct { // the parent directory of a file hierarchy, or just a file. ParentFd int } + +// NetServiceAttr specifies which ports can be used for what. +type NetServiceAttr struct { + AllowedAccess uint64 + Port uint16 +} diff --git a/landlock/syscall/syscall_linux.go b/landlock/syscall/syscall_linux.go index 1519862..8a78980 100644 --- a/landlock/syscall/syscall_linux.go +++ b/landlock/syscall/syscall_linux.go @@ -31,8 +31,11 @@ func LandlockGetABIVersion() (version int, err error) { return } -// There is currently only one Landlock rule type. -const RuleTypePathBeneath = unix.LANDLOCK_RULE_PATH_BENEATH +// Landlock rule types. +const ( + RuleTypePathBeneath = unix.LANDLOCK_RULE_PATH_BENEATH + RuleTypeNetService = 2 // TODO: Use it from sys/unix when available. +) // LandlockAddPathBeneathRule adds a rule of type "path beneath" to // the given ruleset fd. attr defines the rule parameters. flags must @@ -41,6 +44,12 @@ func LandlockAddPathBeneathRule(rulesetFd int, attr *PathBeneathAttr, flags int) return LandlockAddRule(rulesetFd, RuleTypePathBeneath, unsafe.Pointer(attr), flags) } +// LandlockAddNetServiceRule adds a rule of type "net service" to the given ruleset FD. +// attr defines the rule parameters. flags must currently be 0. +func LandlockAddNetServiceRule(rulesetFD int, attr *NetServiceAttr, flags int) error { + return LandlockAddRule(rulesetFD, RuleTypeNetService, unsafe.Pointer(attr), flags) +} + // LandlockAddRule is the generic landlock_add_rule syscall. func LandlockAddRule(rulesetFd int, ruleType int, ruleAttr unsafe.Pointer, flags int) (err error) { _, _, e1 := syscall.Syscall6(unix.SYS_LANDLOCK_ADD_RULE, uintptr(rulesetFd), uintptr(ruleType), uintptr(ruleAttr), uintptr(flags), 0, 0) diff --git a/landlock/syscall/syscall_nonlinux.go b/landlock/syscall/syscall_nonlinux.go index 95b1f97..c8ec3ff 100644 --- a/landlock/syscall/syscall_nonlinux.go +++ b/landlock/syscall/syscall_nonlinux.go @@ -23,6 +23,10 @@ func LandlockAddPathBeneathRule(rulesetFd int, attr *PathBeneathAttr, flags int) return syscall.ENOSYS } +func LandlockAddNetServiceRule(rulesetFD int, attr *NetServiceAttr, flags int) error { + return syscall.ENOSYS +} + func AllThreadsLandlockRestrictSelf(rulesetFd int, flags int) (err error) { return syscall.ENOSYS }