Skip to content

Commit d123bea

Browse files
fix(node)!: enforce owner-only push by default
A `did:key` signature is authentication, not authorization: the method is self-certifying, so any party can generate a keypair, derive its DID and sign. With `GITLAWB_ENFORCE_OWNER_PUSH` defaulting to `false`, `owner_push_rejection` short-circuited and every `git-receive-pack` carrying any valid signature was accepted — including pushes to a repository the signer does not own, and including private ones, on every branch not explicitly protected. The gate itself already worked when switched on. What was missing is that no reachable default configuration switched it on, so a node started with nothing configured accepted a push from anyone. That default is what this changes. The argument now takes a value so there is a way back: `--enforce-owner-push false` and `GITLAWB_ENFORCE_OWNER_PUSH=false` both disable it, while the bare `--enforce-owner-push` form still means `true`. Without this the flag would be presence-only (`ArgAction::SetTrue`), which with a `true` default would leave operators no way to opt out during a rolling upgrade. Three concurrency tests pushed as non-owners in order to reach the shedding logic they actually assert on. They now push as the repo owner, so they keep exercising the shipped default rather than opting out of the gate. Docs updated in README.md, .env.example and docs/RUN-A-NODE.md, including the delegated-key caveat below. README also carries a dated cutover for `GITLAWB_REQUIRE_SIGNED_PEER_WRITES`, which stays `false` for now so live peers can finish upgrading. BREAKING CHANGE: `git-receive-pack` now rejects a push whose authenticated DID is not the repo owner, returning 403 before any ref update is applied. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so an agent pushing under its own DID cannot push while this is on. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` during a rolling upgrade, or have automation push as the repo owner, until scoped collaborator / UCAN-delegated push rights land. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cc1b12d commit d123bea

5 files changed

Lines changed: 115 additions & 30 deletions

File tree

‎.env.example‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,14 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
9494

9595
# Require the authenticated pusher to be the repo owner on git-receive-pack.
9696
# A valid did:key signature is authentication, not authorization: anyone can
97-
# sign as their own DID. When true, pushes from a non-owner DID are rejected.
98-
# Keep false until the repo owner is ready for owner-only writes.
99-
GITLAWB_ENFORCE_OWNER_PUSH=false
97+
# mint a key, derive its DID and sign, so with this off every signed caller may
98+
# push to every repository, private ones included. On by default.
99+
#
100+
# Set to false only for a rolling upgrade whose pushers are not yet the repo
101+
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
102+
# verified but not yet honored for authorization, so they cannot push while this
103+
# is on. See docs/RUN-A-NODE.md.
104+
GITLAWB_ENFORCE_OWNER_PUSH=true
100105

101106
# Comma-separated libp2p multiaddrs.
102107
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...

‎README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,13 @@ When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accep
315315
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
316316
```
317317

318+
**Planned cutover: this default flips to `true` on 15 September 2026.** Until then a
319+
node started with no configuration still accepts unsigned announces, which is an open
320+
write surface kept open only so live peers can finish upgrading. Operators should set it
321+
to `true` now; after the cutover, setting it to `false` becomes the explicit opt-out.
322+
This mirrors what `GITLAWB_ENFORCE_OWNER_PUSH` has already done — that one defaults to
323+
`true` today.
324+
318325
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
319326

320327
---
@@ -340,7 +347,8 @@ Important node settings:
340347
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
341348
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
342349
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
343-
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
350+
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
351+
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
344352
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
345353
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
346354
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5622,7 +5622,10 @@ mod tests {
56225622
.await
56235623
.unwrap();
56245624

5625-
let did = "did:key:z6MkReceivePackWriteCapProofDidAAAAAAAAAA";
5625+
// Owner of the `rp4` row, so the push reaches the per-source write cap instead
5626+
// of stopping at the owner-push gate (on by default). `did_matches` collapses
5627+
// the `did:key:` prefix against the bare stored owner.
5628+
let did = "did:key:z6rp4wr";
56265629
let capped: SocketAddr = "203.0.113.44:5000".parse().unwrap();
56275630
let other: SocketAddr = "203.0.113.45:5000".parse().unwrap();
56285631

@@ -5741,7 +5744,10 @@ mod tests {
57415744
.await
57425745
.expect("hold the advisory lock on the second connection");
57435746

5744-
let did = "did:key:z6MkAcquireDeadlineProofDidAAAAAAAAAAAAAAAA";
5747+
// Owner of the seeded rows, so the push reaches `acquire_write` instead of
5748+
// stopping at the owner-push gate (on by default). `did_matches` collapses the
5749+
// `did:key:` prefix against the bare `owner` above.
5750+
let did = "did:key:z6acqdead";
57455751
let peer: SocketAddr = "203.0.113.61:5000".parse().unwrap();
57465752

57475753
let sem = state.git_write_semaphore.clone();
@@ -8584,7 +8590,11 @@ mod tests {
85848590
.unwrap();
85858591
}
85868592

8587-
let did = "did:key:z6MkF1KeyPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
8593+
// The pusher must be the repos' owner: this test is about the write-cap key's
8594+
// shape, so the push has to reach the cap rather than stop at the owner-push
8595+
// gate, which is on by default. `did_matches` collapses the `did:key:` prefix,
8596+
// so this is the same identity as the bare `z6f1key` the rows are owned by.
8597+
let did = "did:key:z6f1key";
85888598
let capped: SocketAddr = "203.0.113.65:5000".parse().unwrap();
85898599
let other: SocketAddr = "203.0.113.66:5000".parse().unwrap();
85908600
let _slot = state

‎crates/gitlawb-node/src/config.rs‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,21 @@ pub struct Config {
7878

7979
/// Require the authenticated pusher to be the repo owner on `git-receive-pack`.
8080
/// Authentication (a valid did:key signature) is not authorization on its own:
81-
/// any party can sign as their own DID. When true, pushes whose authenticated
82-
/// DID is not the repo owner are rejected. Keep false during rolling upgrades;
83-
/// flip it on once owners are ready for owner-only writes.
84-
#[arg(long, env = "GITLAWB_ENFORCE_OWNER_PUSH", default_value_t = false)]
81+
/// any party can mint a did:key and sign as it, so with this off every signed
82+
/// caller may push to every repository, private ones included. On by default.
83+
///
84+
/// Turn it off only for a rolling upgrade whose pushers are not yet the repo
85+
/// owner. The argument takes a value so there is a way back —
86+
/// `--enforce-owner-push false` and `GITLAWB_ENFORCE_OWNER_PUSH=false` both
87+
/// disable it — while the bare `--enforce-owner-push` form still means `true`.
88+
#[arg(
89+
long,
90+
env = "GITLAWB_ENFORCE_OWNER_PUSH",
91+
action = clap::ArgAction::Set,
92+
num_args = 0..=1,
93+
default_value_t = true,
94+
default_missing_value = "true"
95+
)]
8596
pub enforce_owner_push: bool,
8697

8798
/// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001)
@@ -973,4 +984,43 @@ mod tests {
973984
"db_max_connections at the floor (pushes + headroom) must validate"
974985
);
975986
}
987+
988+
/// A node that is configured with nothing must refuse a non-owner push.
989+
///
990+
/// Authentication is not authorization here: a `did:key` is self-certifying, so
991+
/// anyone can mint one and sign. While `enforce_owner_push` defaulted to `false`,
992+
/// `owner_push_rejection` short-circuited (`api/repos.rs`) and every
993+
/// `git-receive-pack` that carried any valid signature was accepted — including
994+
/// pushes to a repository the signer does not own, and including private ones.
995+
/// The default is the whole point of this test: the gate already worked when
996+
/// switched on, and no reachable default configuration switched it on.
997+
#[test]
998+
fn enforce_owner_push_defaults_to_true() {
999+
assert!(
1000+
Config::parse_from(["gitlawb-node"]).enforce_owner_push,
1001+
"a node started with no configuration must enforce owner-only push"
1002+
);
1003+
}
1004+
1005+
/// The flip must not strand an operator mid-upgrade.
1006+
///
1007+
/// Turning the gate on is a breaking change for any deployment whose pushers are
1008+
/// not yet the repo owner, so the escape hatch has to keep working: the argument
1009+
/// must take a value rather than being presence-only, or `--enforce-owner-push
1010+
/// false` parses as `true` and there is no way back. Asserting the CLI form pins
1011+
/// that the argument is value-taking, which is also what makes
1012+
/// `GITLAWB_ENFORCE_OWNER_PUSH=false` resolve to `false` — env and CLI share one
1013+
/// value parser. The env form is not exercised directly because process
1014+
/// environment is global and these tests run in parallel.
1015+
#[test]
1016+
fn enforce_owner_push_stays_disableable_for_rolling_upgrades() {
1017+
assert!(
1018+
!Config::parse_from(["gitlawb-node", "--enforce-owner-push", "false"])
1019+
.enforce_owner_push,
1020+
"operators must still be able to opt out during a rolling upgrade"
1021+
);
1022+
assert!(
1023+
Config::parse_from(["gitlawb-node", "--enforce-owner-push", "true"]).enforce_owner_push
1024+
);
1025+
}
9761026
}

‎docs/RUN-A-NODE.md‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -141,31 +141,43 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
141141

142142
---
143143

144-
## Hardening: owner-only push
144+
## Owner-only push
145145

146-
By default the node authenticates every `git-receive-pack` push (a valid RFC 9421
147-
`did:key` signature) but does **not** check that the pusher owns the repo, except
148-
on branches that are explicitly protected. Because `did:key` is self-certifying,
149-
any party can generate a key, derive its DID, sign, and push to an unprotected
150-
branch — authentication is not authorization.
146+
The node requires the authenticated pusher to be the repo owner on **every**
147+
branch. A push whose authenticated DID is not the repo owner is rejected with
148+
HTTP 403 before any ref update is applied. The owner is matched in both the full
149+
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
151150

152-
To require the authenticated pusher to be the repo owner on **every** branch, set:
151+
This is on by default, and the default is the point. The node authenticates every
152+
`git-receive-pack` push with a valid RFC 9421 `did:key` signature, but `did:key`
153+
is self-certifying: any party can generate a key, derive its DID and sign.
154+
Authentication is not authorization, so without this gate every signed caller can
155+
push to every repository — private ones included — on any branch that is not
156+
explicitly protected.
157+
158+
### Turning it off
153159

154160
```bash
155-
GITLAWB_ENFORCE_OWNER_PUSH=true
161+
GITLAWB_ENFORCE_OWNER_PUSH=false
156162
```
157163

158-
- **Default `false`** — preserves current behavior so live nodes are unaffected by
159-
an upgrade. Turn it on once you're ready for owner-only writes.
160-
- **When `true`** — a push whose authenticated DID is not the repo owner is
161-
rejected (HTTP 403) before any ref update is applied. The owner is matched in
162-
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
163-
- **Caution: this blocks every non-owner pusher, including your own delegated and
164-
CI agents.** Push authorization is owner-only today — a UCAN `git/push`
165-
capability is verified but not yet honored for authorization, so delegated keys
166-
cannot push while this is on. Don't enable it until every identity that pushes
167-
to your repos is the owner, or you'll lock out your own automation. Scoped
168-
collaborator / UCAN-delegated push rights are a planned follow-up.
164+
Both the environment variable and `--enforce-owner-push false` disable it; the
165+
bare `--enforce-owner-push` flag still means `true`.
166+
167+
Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
168+
treat it as temporary: while it is off, your node accepts a push to any repository
169+
from anyone who can generate a keypair.
170+
171+
### Caution: delegated and CI keys are non-owners
172+
173+
Push authorization is owner-only today. A UCAN `git/push` capability is verified
174+
but **not yet honored for authorization**, so a delegated key cannot push while
175+
this gate is on, even when it holds a valid capability for the repo.
176+
177+
If your automation pushes under its own DID rather than the owner's, it will start
178+
getting 403s. Either have it push as the owner, or set
179+
`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push
180+
rights land — that work is what removes this trade-off.
169181

170182
---
171183

0 commit comments

Comments
 (0)