| 1 | //go:build linux |
| 2 | |
| 3 | package instanceexec |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "net" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "syscall" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | // The probe refuses readiness for each property the hardening adds (#214 |
| 17 | // items 1-3, #178), naming the line. |
| 18 | func TestProbeFailsOnEachMissingHardening(t *testing.T) { |
| 19 | cases := map[string]func(t *testing.T, e *probeEnv){ |
| 20 | "opt-own-build": func(t *testing.T, e *probeEnv) { |
| 21 | if err := os.MkdirAll(filepath.Join(e.optRoot, "versions"), 0o755); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | }, |
| 25 | "dns": func(t *testing.T, e *probeEnv) { |
| 26 | e.sendUDP = func(string, string) error { return syscall.EPERM } |
| 27 | }, |
| 28 | "loopback-v4": func(t *testing.T, e *probeEnv) { |
| 29 | e.sendUDP = func(_, addr string) error { |
| 30 | if addr == "127.0.0.1:9" || addr == "10.0.0.2:53" { |
| 31 | return nil |
| 32 | } |
| 33 | return syscall.EPERM |
| 34 | } |
| 35 | }, |
| 36 | "private-v4": func(t *testing.T, e *probeEnv) { |
| 37 | e.sendUDP = func(_, addr string) error { |
| 38 | if addr == "172.31.255.254:9" || addr == "10.0.0.2:53" { |
| 39 | return nil |
| 40 | } |
| 41 | return syscall.EPERM |
| 42 | } |
| 43 | }, |
| 44 | "private-v6": func(t *testing.T, e *probeEnv) { |
| 45 | e.sendUDP = func(_, addr string) error { |
| 46 | if addr == "[fd00::1]:9" || addr == "10.0.0.2:53" { |
| 47 | return nil |
| 48 | } |
| 49 | return syscall.EPERM |
| 50 | } |
| 51 | }, |
| 52 | "ipc-private": func(t *testing.T, e *probeEnv) { |
| 53 | e.ipcInode = func() (uint64, error) { return initIPCInode, nil } |
| 54 | }, |
| 55 | "abstract-unix": func(t *testing.T, e *probeEnv) { |
| 56 | e.abstractScoped = func() error { return errAbstractReached } |
| 57 | }, |
| 58 | "cpu-limits": func(t *testing.T, e *probeEnv) { |
| 59 | cg, _ := e.selfCgroup() |
| 60 | if err := os.WriteFile(filepath.Join(cg, "cpu.max"), []byte("max 100000\n"), 0o644); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | }, |
| 64 | "io-weight": func(t *testing.T, e *probeEnv) { |
| 65 | cg, _ := e.selfCgroup() |
| 66 | if err := os.WriteFile(filepath.Join(cg, "io.weight"), []byte("default 100\n"), 0o644); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | }, |
| 70 | } |
| 71 | for check, breakIt := range cases { |
| 72 | t.Run(check, func(t *testing.T) { |
| 73 | e := goodEnv(t) |
| 74 | breakIt(t, &e) |
| 75 | r := runChecks(e) |
| 76 | if r.Ready || verdict(r, check) != "fail" { |
| 77 | t.Errorf("%s broken yet ready=%v verdict=%s", check, r.Ready, verdict(r, check)) |
| 78 | } |
| 79 | }) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // A host mqueue visible in the namespace fails ipc-private even with a |
| 84 | // private namespace inode. |
| 85 | func TestProbeFailsOnAVisibleHostMqueue(t *testing.T) { |
| 86 | e := goodEnv(t) |
| 87 | if err := os.WriteFile(filepath.Join(e.devMqueue, "q"), nil, 0o600); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if r := runChecks(e); r.Ready || verdict(r, "ipc-private") != "fail" { |
| 91 | t.Errorf("host mqueue visible yet ready=%v", r.Ready) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // A loopback resolver other than resolved's stub is refused by policy, |
| 96 | // and the probe names it rather than passing on a reachable address. |
| 97 | func TestProbeRefusesALoopbackResolver(t *testing.T) { |
| 98 | e := goodEnv(t) |
| 99 | if err := os.WriteFile(e.resolvConf, []byte("nameserver 127.0.0.1\n"), 0o644); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | e.sendUDP = func(string, string) error { return syscall.EPERM } |
| 103 | if r := runChecks(e); r.Ready || verdict(r, "dns") != "fail" { |
| 104 | t.Errorf("loopback resolver yet ready=%v", r.Ready) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // Unverified, not failed: no IPv6 on the box, and IO weights with no |
| 109 | // controller to enforce them. |
| 110 | func TestProbeUnverifiedLinesDoNotBlock(t *testing.T) { |
| 111 | e := goodEnv(t) |
| 112 | e.sendUDP = func(network, addr string) error { |
| 113 | if addr == "10.0.0.2:53" { |
| 114 | return nil |
| 115 | } |
| 116 | if network == "udp6" { |
| 117 | return syscall.ENETUNREACH |
| 118 | } |
| 119 | return syscall.EPERM |
| 120 | } |
| 121 | e.ioEnforced = func(string) bool { return false } |
| 122 | r := runChecks(e) |
| 123 | if !r.Ready { |
| 124 | t.Fatalf("not ready: %+v", r.Lines) |
| 125 | } |
| 126 | for _, c := range []string{"loopback-v6", "private-v6", "io-weight"} { |
| 127 | if v := verdict(r, c); v != "unverified" { |
| 128 | t.Errorf("%s = %s, want unverified", c, v) |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // The real Landlock scope on this kernel (#178): a scoped thread cannot |
| 134 | // connect to an abstract name bound outside its domain, while the |
| 135 | // unscoped process still can. Skipped where the kernel lacks ABI 6. |
| 136 | func TestScopeAbstractUnixRefusesAnOutsideName(t *testing.T) { |
| 137 | name := "@carlos-scope-test-" + randomName() |
| 138 | ln, err := net.Listen("unix", name) |
| 139 | if err != nil { |
| 140 | t.Skipf("abstract listen: %v", err) |
| 141 | } |
| 142 | defer ln.Close() |
| 143 | go func() { |
| 144 | for { |
| 145 | c, err := ln.Accept() |
| 146 | if err != nil { |
| 147 | return |
| 148 | } |
| 149 | c.Close() |
| 150 | } |
| 151 | }() |
| 152 | res := make(chan error, 1) |
| 153 | go func() { |
| 154 | runtime.LockOSThread() // never unlocked: the thread dies scoped |
| 155 | if err := ScopeAbstractUnix(); err != nil { |
| 156 | res <- err |
| 157 | return |
| 158 | } |
| 159 | c, err := net.Dial("unix", name) |
| 160 | if err == nil { |
| 161 | c.Close() |
| 162 | res <- errAbstractReached |
| 163 | return |
| 164 | } |
| 165 | res <- unwrapErrno(err) |
| 166 | }() |
| 167 | err = <-res |
| 168 | if err != nil && !errors.Is(err, syscall.EPERM) && !errors.Is(err, errAbstractReached) { |
| 169 | t.Skipf("no Landlock scope here: %v", err) |
| 170 | } |
| 171 | if !errors.Is(err, syscall.EPERM) { |
| 172 | t.Fatalf("scoped connect = %v, want EPERM", err) |
| 173 | } |
| 174 | c, err := net.Dial("unix", name) |
| 175 | if err != nil { |
| 176 | t.Fatalf("unscoped connect after the scoped thread: %v", err) |
| 177 | } |
| 178 | c.Close() |
| 179 | if err := realAbstractCheck(); err != nil { |
| 180 | t.Errorf("realAbstractCheck = %v", err) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // An allowed resolver must open only a resolver (#214 item 3, review F1): |
| 185 | // one that answers on a non-DNS port fails resolver-ports. One that is an |
| 186 | // address of this box is the control service's refusal (resolver-local). |
| 187 | func TestProbeResolverPortsLine(t *testing.T) { |
| 188 | e := goodEnv(t) |
| 189 | if r := runChecks(e); verdict(r, "resolver-ports") != "ok" { |
| 190 | t.Fatalf("good fixture: resolver-ports = %s", verdict(r, "resolver-ports")) |
| 191 | } |
| 192 | e.connectTCP = func(addr string, _ time.Duration) error { |
| 193 | if addr == "10.0.0.2:22" { |
| 194 | return nil |
| 195 | } |
| 196 | return syscall.ETIMEDOUT |
| 197 | } |
| 198 | if r := runChecks(e); r.Ready || verdict(r, "resolver-ports") != "fail" { |
| 199 | t.Errorf("resolver answering on 22 yet ready=%v", r.Ready) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // resolved's stub is never a resolver an instance may use: the namespace |
| 204 | // should have replaced it with the upstream list, and if it did not, dns |
| 205 | // fails with the reason. |
| 206 | func TestProbeRefusesTheResolvedStub(t *testing.T) { |
| 207 | e := goodEnv(t) |
| 208 | if err := os.WriteFile(e.resolvConf, []byte("nameserver 127.0.0.53\noptions edns0 trust-ad\n"), 0o644); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | e.sendUDP = func(string, string) error { return nil } |
| 212 | if r := runChecks(e); r.Ready || verdict(r, "dns") != "fail" { |
| 213 | t.Errorf("stub resolver yet ready=%v", r.Ready) |
| 214 | } |
| 215 | } |
| 216 | |