| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "log" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "golang.org/x/sys/unix" |
| 14 | ) |
| 15 | |
| 16 | func TestOffcloudCredentialsCreationAndRepair(t *testing.T) { |
| 17 | if os.Geteuid() != 0 { |
| 18 | t.Skip("root required for root:service ownership") |
| 19 | } |
| 20 | const serviceID = 65534 |
| 21 | for _, owner := range []int{0, serviceID} { |
| 22 | t.Run(map[int]string{0: "creation", serviceID: "repair"}[owner], func(t *testing.T) { |
| 23 | path := filepath.Join(t.TempDir(), "credentials") |
| 24 | contents := []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n") |
| 25 | if err := os.WriteFile(path, contents, 0o600); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | if err := os.Chown(path, owner, serviceID); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | for i := 0; i < 2; i++ { |
| 32 | f, err := openOffcloudCredentials(path) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | err = secureOffcloudCredentials(f, serviceID) |
| 37 | f.Close() |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | var st unix.Stat_t |
| 42 | if err := unix.Lstat(path, &st); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if st.Uid != 0 || st.Gid != serviceID || st.Mode&0o7777 != 0o640 { |
| 46 | t.Fatalf("got owner %d:%d mode %o", st.Uid, st.Gid, st.Mode&0o7777) |
| 47 | } |
| 48 | } |
| 49 | if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) { |
| 50 | t.Fatalf("credentials changed: %q, %v", got, err) |
| 51 | } |
| 52 | }) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestOffcloudCredentialsRepairReplacesWritableInode(t *testing.T) { |
| 57 | if os.Geteuid() != 0 { |
| 58 | t.Skip("root required to replace service-owned credentials with root:service ownership") |
| 59 | } |
| 60 | const serviceID = 65534 |
| 61 | path := filepath.Join(t.TempDir(), "credentials") |
| 62 | contents := []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\naws_session_token=Z\n") |
| 63 | if err := os.WriteFile(path, contents, 0o600); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if err := os.Chown(path, serviceID, serviceID); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | writable, err := os.OpenFile(path, os.O_RDWR, 0) |
| 70 | if err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | defer writable.Close() |
| 74 | old, err := writable.Stat() |
| 75 | if err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | f, err := openOffcloudCredentials(path) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | defer f.Close() |
| 83 | if err := secureOffcloudCredentials(f, serviceID); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | fresh, err := os.Stat(path) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if os.SameFile(old, fresh) { |
| 91 | t.Fatal("repair kept the service-writable inode") |
| 92 | } |
| 93 | if _, err := writable.WriteAt([]byte("[default]\ncredential_process=/bin/false\n"), 0); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) { |
| 97 | t.Fatalf("old writable descriptor affected repaired credentials: %q, %v", got, err) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestOffcloudCredentialsCorrectMetadataUnchanged(t *testing.T) { |
| 102 | if os.Geteuid() != 0 { |
| 103 | t.Skip("root required to verify root:service ownership is left unchanged") |
| 104 | } |
| 105 | const serviceID = 65534 |
| 106 | path := filepath.Join(t.TempDir(), "credentials") |
| 107 | contents := []byte("# preserve this file verbatim\n[default]\naws_access_key_id = X\naws_secret_access_key = Y\n") |
| 108 | if err := os.WriteFile(path, contents, 0o640); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if err := os.Chown(path, 0, serviceID); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | if err := os.Chmod(path, 0o640); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | var before, after unix.Stat_t |
| 118 | if err := unix.Stat(path, &before); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | f, err := openOffcloudCredentials(path) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | defer f.Close() |
| 126 | if err := secureOffcloudCredentials(f, serviceID); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if err := unix.Stat(path, &after); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if before.Ino != after.Ino || before.Ctim != after.Ctim || before.Mode != after.Mode || before.Uid != after.Uid || before.Gid != after.Gid { |
| 133 | t.Fatal("repair mutated already-correct credential metadata") |
| 134 | } |
| 135 | if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) { |
| 136 | t.Fatalf("already-correct credentials changed: %q, %v", got, err) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestOffcloudCredentialsInvalidContentsNotRepaired(t *testing.T) { |
| 141 | for _, contents := range []string{ |
| 142 | "[default]\ncredential_process=/bin/false\n", |
| 143 | "[default]\naws_access_key_id=X\naws_secret_access_key=Y\nregion=eu-west-1\n", |
| 144 | "[default]\naws_access_key_id=X\n", |
| 145 | "[other]\naws_access_key_id=X\naws_secret_access_key=Y\n", |
| 146 | } { |
| 147 | t.Run(strings.ReplaceAll(contents, "\n", ";"), func(t *testing.T) { |
| 148 | path := filepath.Join(t.TempDir(), "credentials") |
| 149 | if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | var before, after unix.Stat_t |
| 153 | if err := unix.Stat(path, &before); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | f, err := openOffcloudCredentials(path) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | defer f.Close() |
| 161 | var logs bytes.Buffer |
| 162 | previous := log.Writer() |
| 163 | log.SetOutput(&logs) |
| 164 | defer log.SetOutput(previous) |
| 165 | if err := secureOffcloudCredentials(f, os.Getegid()); err == nil { |
| 166 | t.Fatal("accepted invalid credentials") |
| 167 | } |
| 168 | if !strings.Contains(logs.String(), "ALERT:") { |
| 169 | t.Fatalf("refusal did not alert: %s", logs.String()) |
| 170 | } |
| 171 | if err := unix.Stat(path, &after); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if before.Ino != after.Ino || before.Ctim != after.Ctim || before.Mode != after.Mode || before.Uid != after.Uid || before.Gid != after.Gid { |
| 175 | t.Fatal("invalid credentials metadata changed") |
| 176 | } |
| 177 | if got, err := os.ReadFile(path); err != nil || string(got) != contents { |
| 178 | t.Fatalf("invalid credentials changed: %q, %v", got, err) |
| 179 | } |
| 180 | }) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestOffcloudCredentialsRefuseLinksAndSpecials(t *testing.T) { |
| 185 | for _, kind := range []string{"symlink", "dangling", "hardlink", "fifo", "directory", "parent-symlink"} { |
| 186 | t.Run(kind, func(t *testing.T) { |
| 187 | root := t.TempDir() |
| 188 | path := filepath.Join(root, "credentials") |
| 189 | victim := filepath.Join(root, "victim") |
| 190 | if err := os.WriteFile(victim, []byte("preserve"), 0o600); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | var err error |
| 194 | switch kind { |
| 195 | case "symlink": |
| 196 | err = os.Symlink(victim, path) |
| 197 | case "dangling": |
| 198 | err = os.Symlink(filepath.Join(root, "absent"), path) |
| 199 | case "hardlink": |
| 200 | err = os.Link(victim, path) |
| 201 | case "fifo": |
| 202 | err = unix.Mkfifo(path, 0o600) |
| 203 | case "directory": |
| 204 | err = os.Mkdir(path, 0o600) |
| 205 | case "parent-symlink": |
| 206 | err = os.Symlink(root, filepath.Join(root, "linked-parent")) |
| 207 | path = filepath.Join(root, "linked-parent", "victim") |
| 208 | } |
| 209 | if err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | var logs bytes.Buffer |
| 213 | previous := log.Writer() |
| 214 | log.SetOutput(&logs) |
| 215 | defer log.SetOutput(previous) |
| 216 | if err := repairOffcloudCredentials(path); err == nil { |
| 217 | t.Fatal("accepted unsafe credentials") |
| 218 | } |
| 219 | if !strings.Contains(logs.String(), "ALERT:") { |
| 220 | t.Fatalf("refusal did not alert: %s", logs.String()) |
| 221 | } |
| 222 | fi, err := os.Stat(victim) |
| 223 | if err != nil || fi.Mode().Perm() != 0o600 { |
| 224 | t.Fatalf("victim mode changed: %v, %v", fi, err) |
| 225 | } |
| 226 | if got, err := os.ReadFile(victim); err != nil || string(got) != "preserve" { |
| 227 | t.Fatalf("victim changed: %q, %v", got, err) |
| 228 | } |
| 229 | }) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestOffcloudCredentialsMissingCloudFile(t *testing.T) { |
| 234 | if err := repairOffcloudCredentials(filepath.Join(t.TempDir(), "absent", "credentials")); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestBootstrapOffcloudAcceptsRepairedCredentials(t *testing.T) { |
| 240 | root := t.TempDir() |
| 241 | path := filepath.Join(root, "etc", "carlos", "credentials") |
| 242 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | if err := os.WriteFile(path, []byte("[default]\n"), 0o640); err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | if err := bootstrapOffcloud(root, nil); err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | } |
| 252 | |