vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Close the approval-forgery hole: check every write, not just additions
362932289cfbecbca4f9937def07b21671bc1aee · Keenan Nemetz · 17d ago · parent 1eeca4e6
Close the approval-forgery hole: check every write, not just additions The check that an approval is written by the principal it names used `--diff-filter=A`, so it only looked at newly added files. An approval that already existed could simply be edited: approve commit A, then rewrite approvals/<me>.json to name commit B and land B with the approver's name on it. A test now demonstrates that exact forgery before the fix and refuses it after. The check is now driven by the change status of every path: any non-deletion on `approvals/<name>.json` requires the actor to be `<name>`, and the first push (no old commit to diff, so every approval in the tree is new) is checked the same way. Deletion is limited to the owner, because removing someone else's approval forges nothing but erases their statement; an admin may still do it, and that is written to the audit log rather than happening silently. Renames land in the same net: without rename detection a move appears as an add, which the check sees, and the destination path is what the check reads. Also worth recording what the enforcement actually rests on, since the file name is the only name that carries authority: the SSH handshake resolves the presented key to a user through data/trusted_keys/<user>.keys, that mapping is one-to-one (a key already owned by another user is refused at add time and fatal at load), and the resolved name reaches the hook as VOLE_ACTOR, which the client cannot set because the exec surface is a two-entry whitelist. `author` fields in meta.json remain display-only and decide nothing. The remaining limitation is honest: this is enforcement at push time, not proof at read time. A clone shows that an approval file exists, not that the server saw its owner write it. Signed attestations in Phase 7 are what would make it verifiable offline.
internal/server/integration_test.go +133−5
| @@ -1048,13 +1048,19 @@ func (f *fixture) newMetaRepo(t *testing.T) *metaRepo { | |||
| 1048 | 1048 | return &metaRepo{t: t, f: f, dir: dir, key: f.keyPath} | |
| 1049 | 1049 | } | |
| 1050 | 1050 | ||
| 1051 | func (m *metaRepo) apply(files map[string]string, msg string) error { | ||
| 1051 | // sync brings the worktree to the server's metadata tip. | ||
| 1052 | func (m *metaRepo) sync() { | ||
| 1052 | 1053 | m.t.Helper() | |
| 1053 | 1054 | if _, _, err := fgit(m.t, m.key, m.dir, "fetch", "-q", "origin", "+refs/heads/vole:refs/heads/meta-base"); err == nil { | |
| 1054 | 1055 | if _, errs, rerr := runGit(m.t, m.dir, "reset", "-q", "--hard", "refs/heads/meta-base"); rerr != nil { | |
| 1055 | 1056 | m.t.Fatalf("reset: %v: %s", rerr, errs) | |
| 1056 | 1057 | } | |
| 1057 | 1058 | } | |
| 1059 | } | ||
| 1060 | |||
| 1061 | func (m *metaRepo) apply(files map[string]string, msg string) error { | ||
| 1062 | m.t.Helper() | ||
| 1063 | m.sync() | ||
| 1058 | 1064 | for name, content := range files { | |
| 1059 | 1065 | p := filepath.Join(m.dir, name) | |
| 1060 | 1066 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| @@ -1352,8 +1358,8 @@ func TestImpersonatedApprovalRejected(t *testing.T) { | |||
| 1352 | 1358 | if err == nil { | |
| 1353 | 1359 | t.Fatal("a writer approved in another principal's name") | |
| 1354 | 1360 | } | |
| 1355 | if !strings.Contains(err.Error(), "may not add an approval in keenan's name") { | ||
| 1356 | t.Fatalf("unexpected error: %v", err) | ||
| 1361 | if !strings.Contains(err.Error(), "keenan's approval") && !strings.Contains(err.Error(), "in keenan's name") { | ||
| 1362 | t.Fatalf("rejection does not name the impersonated principal: %v", err) | ||
| 1357 | 1363 | } | |
| 1358 | 1364 | } | |
| 1359 | 1365 | ||
| @@ -1427,8 +1433,130 @@ func TestFirstPushCannotSeedForeignApprovals(t *testing.T) { | |||
| 1427 | 1433 | if err == nil { | |
| 1428 | 1434 | t.Fatal("the first push seeded an approval in another principal's name") | |
| 1429 | 1435 | } | |
| 1430 | if !strings.Contains(err.Error(), "may not add an approval in keenan's name") { | ||
| 1431 | t.Fatalf("unexpected error: %v", err) | ||
| 1436 | if !strings.Contains(err.Error(), "keenan's approval") && !strings.Contains(err.Error(), "in keenan's name") { | ||
| 1437 | t.Fatalf("rejection does not name the impersonated principal: %v", err) | ||
| 1438 | } | ||
| 1439 | } | ||
| 1440 | |||
| 1441 | // TestModifiedApprovalCannotBeForged: an approval file that already exists can be | ||
| 1442 | // *edited* rather than added, so a check that only inspects added paths misses it. | ||
| 1443 | // This is the exploit: approve commit A, then rewrite the approval to name B. | ||
| 1444 | func TestModifiedApprovalCannotBeForged(t *testing.T) { | ||
| 1445 | f := newFixture(t) | ||
| 1446 | writerKey := f.addWriter(t) | ||
| 1447 | f.publish(f.repo) | ||
| 1448 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 1449 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 1450 | t.Fatalf("initial main: %s", errs) | ||
| 1451 | } | ||
| 1452 | m := mustRepo(t, f) | ||
| 1453 | a, _ := m.ACL() | ||
| 1454 | a.Protected = []string{"refs/heads/main"} | ||
| 1455 | if err := m.SetACL(a, "test"); err != nil { | ||
| 1456 | t.Fatal(err) | ||
| 1457 | } | ||
| 1458 | |||
| 1459 | feature, approvedHead := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature") | ||
| 1460 | if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 1461 | t.Fatalf("feature: %s", errs) | ||
| 1462 | } | ||
| 1463 | meta := f.newMetaRepo(t) | ||
| 1464 | if err := meta.apply(map[string]string{ | ||
| 1465 | "requests/" + reqID + "/meta.json": requestMeta("main", approvedHead, "wilbur"), | ||
| 1466 | }, "request"); err != nil { | ||
| 1467 | t.Fatalf("request: %v", err) | ||
| 1468 | } | ||
| 1469 | approver := f.newMetaRepo(t) | ||
| 1470 | if err := approver.apply(map[string]string{ | ||
| 1471 | "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", approvedHead), | ||
| 1472 | }, "approve"); err != nil { | ||
| 1473 | t.Fatalf("approve: %v", err) | ||
| 1474 | } | ||
| 1475 | |||
| 1476 | // wilbur amends the feature to a commit keenan never saw. | ||
| 1477 | if _, errs, err := runGit(t, feature, "commit", "-q", "--amend", "-m", "unreviewed"); err != nil { | ||
| 1478 | t.Fatalf("amend: %v: %s", err, errs) | ||
| 1479 | } | ||
| 1480 | out, _, _ := runGit(t, feature, "rev-parse", "HEAD") | ||
| 1481 | sneaky := strings.TrimSpace(out) | ||
| 1482 | |||
| 1483 | // He repoints both his request and keenan's approval at it. | ||
| 1484 | forger := f.newMetaRepo(t) | ||
| 1485 | forger.key = writerKey | ||
| 1486 | err := forger.apply(map[string]string{ | ||
| 1487 | "requests/" + reqID + "/meta.json": requestMeta("main", sneaky, "wilbur"), | ||
| 1488 | "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", sneaky), | ||
| 1489 | }, "repoint approval") | ||
| 1490 | if err == nil { | ||
| 1491 | t.Fatal("a writer rewrote another principal's approval and the push was accepted") | ||
| 1492 | } | ||
| 1493 | if !strings.Contains(err.Error(), "keenan") { | ||
| 1494 | t.Fatalf("rejection does not name the impersonated principal: %v", err) | ||
| 1495 | } | ||
| 1496 | } | ||
| 1497 | |||
| 1498 | // TestApprovalDeletionIsOwnerOrAdmin: deleting a file forges nothing, but it | ||
| 1499 | // erases someone else's statement, so a writer may not do it to another person. | ||
| 1500 | func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) { | ||
| 1501 | f := newFixture(t) | ||
| 1502 | writerKey := f.addWriter(t) | ||
| 1503 | f.publish(f.repo) | ||
| 1504 | if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}), | ||
| 1505 | "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 1506 | t.Fatalf("initial main: %s", errs) | ||
| 1507 | } | ||
| 1508 | _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change") | ||
| 1509 | |||
| 1510 | // wilbur authors the request; keenan approves it. | ||
| 1511 | meta := f.newMetaRepo(t) | ||
| 1512 | meta.key = writerKey | ||
| 1513 | if err := meta.apply(map[string]string{ | ||
| 1514 | "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"), | ||
| 1515 | }, "request"); err != nil { | ||
| 1516 | t.Fatalf("setup: %v", err) | ||
| 1517 | } | ||
| 1518 | approver := f.newMetaRepo(t) | ||
| 1519 | if err := approver.apply(map[string]string{ | ||
| 1520 | "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head), | ||
| 1521 | }, "approve"); err != nil { | ||
| 1522 | t.Fatalf("approve: %v", err) | ||
| 1523 | } | ||
| 1524 | |||
| 1525 | // wilbur erases keenan's approval. | ||
| 1526 | deleter := f.newMetaRepo(t) | ||
| 1527 | deleter.key = writerKey | ||
| 1528 | deleter.sync() | ||
| 1529 | if err := os.Remove(filepath.Join(deleter.dir, "requests", reqID, "approvals", "keenan.json")); err != nil { | ||
| 1530 | t.Fatal(err) | ||
| 1531 | } | ||
| 1532 | if _, errs, err := runGit(t, deleter.dir, "add", "-A"); err != nil { | ||
| 1533 | t.Fatalf("add: %v: %s", err, errs) | ||
| 1534 | } | ||
| 1535 | if _, errs, err := runGit(t, deleter.dir, "commit", "-qm", "drop approval"); err != nil { | ||
| 1536 | t.Fatalf("commit: %v: %s", err, errs) | ||
| 1537 | } | ||
| 1538 | _, errs, err := fgit(t, writerKey, deleter.dir, "push", "origin", "HEAD:refs/heads/vole") | ||
| 1539 | if err == nil { | ||
| 1540 | t.Fatal("a writer deleted another principal's approval") | ||
| 1541 | } | ||
| 1542 | if !strings.Contains(errs, "may not delete keenan's approval") { | ||
| 1543 | t.Fatalf("unexpected rejection: %s", errs) | ||
| 1544 | } | ||
| 1545 | |||
| 1546 | // keenan withdraws his own. | ||
| 1547 | own := f.newMetaRepo(t) | ||
| 1548 | own.sync() | ||
| 1549 | if err := os.Remove(filepath.Join(own.dir, "requests", reqID, "approvals", "keenan.json")); err != nil { | ||
| 1550 | t.Fatal(err) | ||
| 1551 | } | ||
| 1552 | if _, errs, cerr := runGit(t, own.dir, "add", "-A"); cerr != nil { | ||
| 1553 | t.Fatalf("add: %v: %s", cerr, errs) | ||
| 1554 | } | ||
| 1555 | if _, errs, cerr := runGit(t, own.dir, "commit", "-qm", "withdraw my approval"); cerr != nil { | ||
| 1556 | t.Fatalf("commit: %v: %s", cerr, errs) | ||
| 1557 | } | ||
| 1558 | if _, errs, cerr := fgit(t, f.keyPath, own.dir, "push", "origin", "HEAD:refs/heads/vole"); cerr != nil { | ||
| 1559 | t.Fatalf("owner could not withdraw their own approval: %s", errs) | ||
| 1432 | 1560 | } | |
| 1433 | 1561 | } | |
| 1434 | 1562 | ||