Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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