Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

knotserver/sandbox: narrow global config grant to single file

- Add unit and integration tests for sandbox and path behavior
- Define a RuleSpec to construct Landlock ruleset
- Enforce $HOME/.config/git/config for git config (was previously
granting the entirety of $HOME)

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

author
Anirudh Oppiliappan
committer
Tangled
date (Jun 17, 2026, 11:38 AM +0300) commit 86d1e3d3 parent f7c606c7 change-id kyxspmpu
+379 -28
+250
knotserver/sandbox/sandbox_integration_test.go
··· 1 + //go:build linux && integration 2 + 3 + package sandbox 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + "os/exec" 9 + "path/filepath" 10 + "strings" 11 + "testing" 12 + 13 + "github.com/landlock-lsm/go-landlock/landlock" 14 + ) 15 + 16 + // This file exercises actual Landlock enforcement. It works by re-execing the 17 + // test binary with an env var that makes it run the "child" code path: the 18 + // child applies the ruleset built by BuildRuleSpec and attempts a specific 19 + // filesystem operation, exiting 0 (allowed) or non-zero (denied). 20 + // 21 + // Build tag: linux && integration. Run with: 22 + // go test -tags integration ./knotserver/sandbox/... 23 + 24 + const ( 25 + childEnv = "TANGLED_SANDBOX_INT_CHILD" 26 + childRepo = "TANGLED_SANDBOX_INT_REPO" 27 + childTarget = "TANGLED_SANDBOX_INT_TARGET" 28 + childOp = "TANGLED_SANDBOX_INT_OP" 29 + ) 30 + 31 + func TestMain(m *testing.M) { 32 + if os.Getenv(childEnv) == "1" { 33 + runChild() 34 + return 35 + } 36 + os.Exit(m.Run()) 37 + } 38 + 39 + func runChild() { 40 + repoPath := os.Getenv(childRepo) 41 + target := os.Getenv(childTarget) 42 + op := os.Getenv(childOp) 43 + 44 + spec := BuildRuleSpec([]string{repoPath}) 45 + rules := []landlock.Rule{ 46 + landlock.RODirs(spec.SystemRO...).IgnoreIfMissing(), 47 + landlock.RWFiles(spec.DevRW...).WithIoctlDev().IgnoreIfMissing(), 48 + landlock.RWDirs(spec.TmpRW...).IgnoreIfMissing(), 49 + } 50 + if spec.GitConfigRO != "" { 51 + rules = append(rules, landlock.ROFiles(spec.GitConfigRO).IgnoreIfMissing()) 52 + } 53 + for _, p := range spec.RepoRW { 54 + rules = append(rules, landlock.RWDirs(p).WithRefer()) 55 + } 56 + if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil { 57 + fmt.Fprintf(os.Stderr, "restrict failed: %v\n", err) 58 + os.Exit(2) 59 + } 60 + 61 + var opErr error 62 + switch op { 63 + case "read": 64 + _, opErr = os.ReadFile(target) 65 + case "write": 66 + opErr = os.WriteFile(target, []byte("x"), 0644) 67 + case "list": 68 + _, opErr = os.ReadDir(target) 69 + default: 70 + fmt.Fprintf(os.Stderr, "unknown op %q\n", op) 71 + os.Exit(3) 72 + } 73 + 74 + if opErr != nil { 75 + fmt.Fprintln(os.Stderr, opErr) 76 + os.Exit(1) 77 + } 78 + os.Exit(0) 79 + } 80 + 81 + // runUnderSandbox spawns the test binary as a child, applies Landlock with 82 + // the given repoPath as the granted RW path, then attempts op on target. 83 + // Returns true if the op was allowed, false if denied. 84 + func runUnderSandbox(t *testing.T, repoPath, target, op string) (allowed bool, output string) { 85 + t.Helper() 86 + cmd := exec.Command(os.Args[0]) 87 + cmd.Env = append(os.Environ(), 88 + childEnv+"=1", 89 + childRepo+"="+repoPath, 90 + childTarget+"="+target, 91 + childOp+"="+op, 92 + ) 93 + out, err := cmd.CombinedOutput() 94 + if err == nil { 95 + return true, string(out) 96 + } 97 + if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() == 1 { 98 + return false, string(out) 99 + } 100 + t.Fatalf("child exited unexpectedly: %v\noutput: %s", err, out) 101 + return false, "" 102 + } 103 + 104 + func skipIfNoLandlock(t *testing.T) { 105 + t.Helper() 106 + if !probeLandlock() { 107 + t.Skip("Landlock not available on this kernel") 108 + } 109 + } 110 + 111 + func TestSandboxIntegration_AllowsOwnRepoRead(t *testing.T) { 112 + skipIfNoLandlock(t) 113 + 114 + root := t.TempDir() 115 + repo := filepath.Join(root, "did:plc:abc") 116 + target := filepath.Join(repo, "HEAD") 117 + mustMkdirAll(t, repo) 118 + mustWriteFile(t, target, "ref: refs/heads/main\n") 119 + 120 + allowed, out := runUnderSandbox(t, repo, target, "read") 121 + if !allowed { 122 + t.Errorf("expected read of own repo to be allowed; child said: %s", strings.TrimSpace(out)) 123 + } 124 + } 125 + 126 + func TestSandboxIntegration_DeniesOtherRepoRead(t *testing.T) { 127 + skipIfNoLandlock(t) 128 + 129 + root := t.TempDir() 130 + myRepo := filepath.Join(root, "did:plc:abc") 131 + otherRepo := filepath.Join(root, "did:plc:xyz") 132 + secret := filepath.Join(otherRepo, "secret-key") 133 + mustMkdirAll(t, myRepo) 134 + mustMkdirAll(t, otherRepo) 135 + mustWriteFile(t, secret, "TOPSECRET\n") 136 + 137 + allowed, out := runUnderSandbox(t, myRepo, secret, "read") 138 + if allowed { 139 + t.Errorf("expected read of other repo to be denied; child read: %s", strings.TrimSpace(out)) 140 + } 141 + } 142 + 143 + func TestSandboxIntegration_AllowsGlobalConfigRead(t *testing.T) { 144 + skipIfNoLandlock(t) 145 + 146 + root := t.TempDir() 147 + myRepo := filepath.Join(root, "did:plc:abc") 148 + cfgDir := filepath.Join(root, ".config", "git") 149 + cfg := filepath.Join(cfgDir, "config") 150 + mustMkdirAll(t, myRepo) 151 + mustMkdirAll(t, cfgDir) 152 + mustWriteFile(t, cfg, "[user]\n\tname = test\n") 153 + 154 + // the child reads $HOME via BuildRuleSpec, so set HOME for it explicitly. 155 + t.Setenv("HOME", root) 156 + 157 + allowed, out := runUnderSandbox(t, myRepo, cfg, "read") 158 + if !allowed { 159 + t.Errorf("expected read of $HOME/.config/git/config to be allowed; child said: %s", strings.TrimSpace(out)) 160 + } 161 + } 162 + 163 + func TestSandboxIntegration_DeniesGlobalConfigSibling(t *testing.T) { 164 + skipIfNoLandlock(t) 165 + 166 + root := t.TempDir() 167 + myRepo := filepath.Join(root, "did:plc:abc") 168 + cfgDir := filepath.Join(root, ".config", "git") 169 + sibling := filepath.Join(cfgDir, "attributes") 170 + mustMkdirAll(t, myRepo) 171 + mustMkdirAll(t, cfgDir) 172 + mustWriteFile(t, sibling, "*.bin -text\n") 173 + 174 + t.Setenv("HOME", root) 175 + 176 + allowed, out := runUnderSandbox(t, myRepo, sibling, "read") 177 + if allowed { 178 + t.Errorf("expected read of sibling in .config/git/ to be denied (only the config file is granted); child read: %s", strings.TrimSpace(out)) 179 + } 180 + } 181 + 182 + func TestSandboxIntegration_DeniesScanPathSibling(t *testing.T) { 183 + skipIfNoLandlock(t) 184 + 185 + root := t.TempDir() 186 + myRepo := filepath.Join(root, "did:plc:abc") 187 + dbFile := filepath.Join(root, "knotserver.db") 188 + mustMkdirAll(t, myRepo) 189 + mustWriteFile(t, dbFile, "fake db contents\n") 190 + 191 + allowed, out := runUnderSandbox(t, myRepo, dbFile, "read") 192 + if allowed { 193 + t.Errorf("expected read of sibling file (knotserver.db) to be denied; child read: %s", strings.TrimSpace(out)) 194 + } 195 + } 196 + 197 + func TestSandboxIntegration_DeniesScanPathListing(t *testing.T) { 198 + skipIfNoLandlock(t) 199 + 200 + root := t.TempDir() 201 + myRepo := filepath.Join(root, "did:plc:abc") 202 + mustMkdirAll(t, myRepo) 203 + 204 + allowed, out := runUnderSandbox(t, myRepo, root, "list") 205 + if allowed { 206 + t.Errorf("expected listing scan path to be denied; child output: %s", strings.TrimSpace(out)) 207 + } 208 + } 209 + 210 + func TestSandboxIntegration_AllowsOwnRepoWrite(t *testing.T) { 211 + skipIfNoLandlock(t) 212 + 213 + root := t.TempDir() 214 + repo := filepath.Join(root, "did:plc:abc") 215 + target := filepath.Join(repo, "new-file") 216 + mustMkdirAll(t, repo) 217 + 218 + allowed, out := runUnderSandbox(t, repo, target, "write") 219 + if !allowed { 220 + t.Errorf("expected write to own repo to be allowed; child said: %s", strings.TrimSpace(out)) 221 + } 222 + } 223 + 224 + func TestSandboxIntegration_DeniesSystemWrite(t *testing.T) { 225 + skipIfNoLandlock(t) 226 + 227 + root := t.TempDir() 228 + repo := filepath.Join(root, "did:plc:abc") 229 + mustMkdirAll(t, repo) 230 + 231 + // /etc is granted RO; writes must be denied. 232 + allowed, out := runUnderSandbox(t, repo, "/etc/sandbox-test-should-fail", "write") 233 + if allowed { 234 + t.Errorf("expected write to /etc to be denied; child output: %s", strings.TrimSpace(out)) 235 + } 236 + } 237 + 238 + func mustMkdirAll(t *testing.T, p string) { 239 + t.Helper() 240 + if err := os.MkdirAll(p, 0o755); err != nil { 241 + t.Fatal(err) 242 + } 243 + } 244 + 245 + func mustWriteFile(t *testing.T, p, content string) { 246 + t.Helper() 247 + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { 248 + t.Fatal(err) 249 + } 250 + }
+58 -28
knotserver/sandbox/sandbox_linux.go
··· 80 80 81 81 func (l *LandlockBackend) Name() string { return "landlock" } 82 82 83 + // RuleSpec describes the paths a sandbox should grant access to, grouped by 84 + // access tier. It is the input to the Landlock ruleset construction and is 85 + // exposed so the path-derivation logic can be tested independently of any 86 + // actual kernel-level enforcement. 87 + type RuleSpec struct { 88 + // SystemRO is the set of system directories granted read+execute. 89 + SystemRO []string 90 + // GitConfigRO is the global git config file, granted read-only access 91 + // at file granularity. Empty when $HOME is not set. 92 + GitConfigRO string 93 + // DevRW is the set of device-file directories granted read/write + 94 + // ioctl access (needed so /dev/null works under Landlock V5+). 95 + DevRW []string 96 + // TmpRW is the set of directories granted read/write for temporary 97 + // patch and object files. 98 + TmpRW []string 99 + // RepoRW is the set of repository directories granted read/write 100 + // access including the REFER right (for cross-directory rename in 101 + // receive-pack's quarantine migration). 102 + RepoRW []string 103 + } 104 + 105 + // BuildRuleSpec derives the set of paths the sandbox should grant to each 106 + // access tier given the repository paths the subprocess operates on. 107 + func BuildRuleSpec(repoPaths []string) RuleSpec { 108 + return buildRuleSpec(repoPaths, os.Getenv("HOME")) 109 + } 110 + 111 + // buildRuleSpec is the testable variant of BuildRuleSpec that takes $HOME 112 + // explicitly instead of reading it from the environment. 113 + func buildRuleSpec(repoPaths []string, home string) RuleSpec { 114 + var gitConfig string 115 + if home != "" { 116 + // the only thing the sandboxed git subprocess needs from $HOME is the 117 + // global config file. granting just that one file (not the whole 118 + // .config tree) keeps everything else under $HOME outside the ruleset. 119 + gitConfig = filepath.Join(home, ".config", "git", "config") 120 + } 121 + 122 + return RuleSpec{ 123 + SystemRO: []string{"/usr", "/bin", "/lib", "/lib64", "/nix", "/etc"}, 124 + GitConfigRO: gitConfig, 125 + DevRW: []string{"/dev"}, 126 + TmpRW: []string{"/tmp"}, 127 + RepoRW: append([]string(nil), repoPaths...), 128 + } 129 + } 130 + 83 131 // ApplyLandlock applies a Landlock ruleset to the current process then 84 132 // exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand. 85 133 func ApplyLandlock(repoPaths []string, gitArgs []string) error { ··· 87 135 return fmt.Errorf("sandbox-exec: no command specified") 88 136 } 89 137 90 - // collect unique parent directories so git can read global config 91 - // under $HOME/.config/git/config. repo contents stay DAC-locked 92 - // (0700) so other repos can't actually be read. 93 - parents := map[string]struct{}{} 94 - for _, p := range repoPaths { 95 - parents[filepath.Dir(p)] = struct{}{} 138 + spec := BuildRuleSpec(repoPaths) 139 + 140 + rules := []landlock.Rule{ 141 + landlock.RODirs(spec.SystemRO...).IgnoreIfMissing(), 142 + landlock.RWFiles(spec.DevRW...).WithIoctlDev().IgnoreIfMissing(), 143 + landlock.RWDirs(spec.TmpRW...).IgnoreIfMissing(), 96 144 } 97 - parentSlice := make([]string, 0, len(parents)) 98 - for p := range parents { 99 - parentSlice = append(parentSlice, p) 145 + if spec.GitConfigRO != "" { 146 + rules = append(rules, landlock.ROFiles(spec.GitConfigRO).IgnoreIfMissing()) 100 147 } 101 - 102 - // each repo gets full read/write plus REFER (needed for git's quarantine 103 - // rename in receive-pack, which moves objects across directories). 104 - repoRules := make([]landlock.Rule, len(repoPaths)) 105 - for i, p := range repoPaths { 106 - repoRules[i] = landlock.RWDirs(p).WithRefer() 148 + for _, p := range spec.RepoRW { 149 + rules = append(rules, landlock.RWDirs(p).WithRefer()) 107 150 } 108 - 109 - rules := append([]landlock.Rule{ 110 - // system dirs: read + execute only, no writes 111 - landlock.RODirs("/usr", "/bin", "/lib", "/lib64", "/nix", "/etc").IgnoreIfMissing(), 112 - // /dev/null and friends: read/write files + ioctl (V5+ restricts ioctl 113 - // on device files; WithIoctlDev keeps /dev/null fully accessible) 114 - landlock.RWFiles("/dev").WithIoctlDev().IgnoreIfMissing(), 115 - // parent dirs: read + execute so git can traverse to the repo and read 116 - // global git config; 0700 DAC permissions prevent cross-repo reads 117 - landlock.RODirs(parentSlice...).IgnoreIfMissing(), 118 - // /tmp: read/write for temporary patch and object files 119 - landlock.RWDirs("/tmp").IgnoreIfMissing(), 120 - }, repoRules...) 121 151 122 152 // V8.BestEffort enforces the strongest ruleset the running kernel supports, 123 153 // up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically.
+71
knotserver/sandbox/sandbox_linux_test.go
··· 4 4 5 5 import ( 6 6 "os/exec" 7 + "reflect" 7 8 "strings" 8 9 "syscall" 9 10 "testing" 10 11 ) 12 + 13 + func TestBuildRuleSpec_SingleRepo(t *testing.T) { 14 + spec := buildRuleSpec([]string{"/home/git/did:plc:abc"}, "/home/git") 15 + 16 + if got, want := spec.GitConfigRO, "/home/git/.config/git/config"; got != want { 17 + t.Errorf("GitConfigRO = %q, want %q", got, want) 18 + } 19 + if got, want := spec.RepoRW, []string{"/home/git/did:plc:abc"}; !reflect.DeepEqual(got, want) { 20 + t.Errorf("RepoRW = %q, want %q", got, want) 21 + } 22 + if got, want := spec.SystemRO, []string{"/usr", "/bin", "/lib", "/lib64", "/nix", "/etc"}; !reflect.DeepEqual(got, want) { 23 + t.Errorf("SystemRO = %q, want %q", got, want) 24 + } 25 + if got, want := spec.TmpRW, []string{"/tmp"}; !reflect.DeepEqual(got, want) { 26 + t.Errorf("TmpRW = %q, want %q", got, want) 27 + } 28 + if got, want := spec.DevRW, []string{"/dev"}; !reflect.DeepEqual(got, want) { 29 + t.Errorf("DevRW = %q, want %q", got, want) 30 + } 31 + } 32 + 33 + func TestBuildRuleSpec_GitConfigFollowsHome(t *testing.T) { 34 + // the granted git config path must follow $HOME, not the repo path. this 35 + // is what makes the merge case work: tmpDir is under /tmp, but the 36 + // subprocess still resolves the global config from $HOME/.config/git/config. 37 + spec := buildRuleSpec([]string{"/tmp/git-clone-XYZ"}, "/home/git") 38 + 39 + if got, want := spec.GitConfigRO, "/home/git/.config/git/config"; got != want { 40 + t.Errorf("GitConfigRO = %q, want %q", got, want) 41 + } 42 + } 43 + 44 + func TestBuildRuleSpec_NoHome(t *testing.T) { 45 + // empty $HOME should not produce a bogus "/.config/git/config" entry. 46 + spec := buildRuleSpec([]string{"/home/git/did:plc:abc"}, "") 47 + 48 + if spec.GitConfigRO != "" { 49 + t.Errorf("GitConfigRO = %q, want empty", spec.GitConfigRO) 50 + } 51 + } 52 + 53 + func TestBuildRuleSpec_NeverGrantsScanPath(t *testing.T) { 54 + // the scan path (the repo's parent) must NEVER appear in any RW or RO 55 + // list. granting it would expose other repos and the knot DB via 56 + // Landlock RO + DAC group bits. this is the key invariant the rule 57 + // tightening was meant to enforce. 58 + spec := buildRuleSpec([]string{"/home/git/did:plc:abc"}, "/home/git") 59 + 60 + parent := "/home/git" 61 + for _, group := range [][]string{spec.SystemRO, spec.DevRW, spec.TmpRW, spec.RepoRW} { 62 + for _, p := range group { 63 + if p == parent { 64 + t.Errorf("scan path %q must not appear in the ruleset; found in %q", parent, group) 65 + } 66 + } 67 + } 68 + if spec.GitConfigRO == parent { 69 + t.Errorf("scan path %q must not be granted as GitConfigRO", parent) 70 + } 71 + } 72 + 73 + func TestBuildRuleSpec_EmptyInput(t *testing.T) { 74 + spec := buildRuleSpec(nil, "") 75 + if spec.GitConfigRO != "" { 76 + t.Errorf("GitConfigRO should be empty for nil input and no $HOME, got %q", spec.GitConfigRO) 77 + } 78 + if len(spec.RepoRW) != 0 { 79 + t.Errorf("RepoRW should be empty for nil input, got %q", spec.RepoRW) 80 + } 81 + } 11 82 12 83 func TestLandlockBackend_Name(t *testing.T) { 13 84 if (&LandlockBackend{}).Name() != "landlock" {