Skip to content

Commit a8847a3

Browse files
gitscratch: optional per-repo ownership (config.owned)
Audit finding: the auth gate required *an* agent but never checked it against the repo's recorded creator, so any authenticated user could push to another's scratch repo. Default stays shared (the scratch model); config.owned: true binds each repo to its first materializer — thereafter only that creator may push (and read, under requireAuth), others get 403. Regression test: alice creates, bob is refused, alice still allowed.
1 parent 642dec2 commit a8847a3

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

gitscratch/plugin.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
// NIP-98, … An anonymous push gets 401 + WWW-Authenticate.
2424
// - clone/fetch is public unless config.requireAuth is true.
2525
// - the agent id is passed to the backend as REMOTE_USER.
26+
// - by default a scratch repo is SHARED: any verified agent may push to
27+
// any repo (that is the "scratch" model). Set config.owned: true to bind
28+
// each repo to the agent that first materialized it — thereafter only
29+
// that creator may push (and, when requireAuth is set, read); others get
30+
// 403. This closes the cross-agent-overwrite gap for deployments that
31+
// want per-repo ownership without changing the shared default.
2632
//
2733
// Raw bodies (relates to JSS #583): the routes live in a Fastify scope
2834
// whose content-type parser hands the *unconsumed request stream* through
@@ -63,6 +69,7 @@ export async function activate(api) {
6369
const ttlMs = api.config.ttlMs ?? DEFAULT_TTL_MS;
6470
const sweepIntervalMs = api.config.sweepIntervalMs ?? Math.min(ttlMs, DEFAULT_SWEEP_MS);
6571
const requireAuth = api.config.requireAuth ?? false;
72+
const owned = api.config.owned ?? false;
6673
const backend = await findBackend(api.config);
6774

6875
const reposDir = path.join(api.storage.pluginDir(), 'repos');
@@ -86,6 +93,14 @@ export async function activate(api) {
8693
'',
8794
].join('\n');
8895

96+
// The agent that first materialized a repo, or null if unknown/shared.
97+
function repoCreator(name) {
98+
try {
99+
const meta = JSON.parse(fs.readFileSync(path.join(reposDir, name, META_FILE), 'utf8'));
100+
return meta.creator ?? null;
101+
} catch { return null; }
102+
}
103+
89104
// ------------------------------------------------------- materialize
90105
async function materialize(name, agent) {
91106
const repoPath = path.join(reposDir, name);
@@ -242,6 +257,16 @@ export async function activate(api) {
242257
return reply.code(401).send('authentication required\n');
243258
}
244259

260+
// Ownership (config.owned): once a repo has a creator, only that agent
261+
// may push to it — and, under requireAuth, read it. Anonymous access is
262+
// already handled above; here `agent` is set whenever the gate matters.
263+
if (owned) {
264+
const creator = repoCreator(name);
265+
if (creator && agent !== creator && (isWrite || requireAuth)) {
266+
return reply.code(403).send('this scratch repo belongs to another agent\n');
267+
}
268+
}
269+
245270
// First access materializes the repo; the TTL clock starts here.
246271
await materialize(name, agent);
247272

gitscratch/test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,41 @@ describe('gitscratch plugin', () => {
195195
}
196196
});
197197

198+
it('owned: true binds a repo to its creator — other agents cannot push', async () => {
199+
const owned = await startJss({
200+
idp: true,
201+
plugins: [{
202+
id: 'gitscratch',
203+
module: module_,
204+
prefix: '/git',
205+
config: { owned: true },
206+
}],
207+
});
208+
try {
209+
const { access_token: aliceTok } = await registerAndMint(owned.base, 'aliceowns');
210+
const { access_token: bobTok } = await registerAndMint(owned.base, 'bobintrudes');
211+
const ownedRemote = `${owned.base}/git/mine.git`;
212+
213+
// Alice creates the repo by first push.
214+
await git([...authFlag(aliceTok), 'push', ownedRemote, 'main'], { cwd: work });
215+
216+
// Bob, though a verified agent, is refused a push to alice's repo.
217+
await assert.rejects(
218+
git([...authFlag(bobTok), 'push', ownedRemote, 'main'], { cwd: work }),
219+
(err) => {
220+
assert.match(String(err.stderr), /403|belongs to another agent|forbidden/i,
221+
`bob's push should be forbidden: ${err.stderr}`);
222+
return true;
223+
},
224+
);
225+
226+
// Alice can still push (a no-op update proves her access survives).
227+
await git([...authFlag(aliceTok), 'push', ownedRemote, 'main'], { cwd: work });
228+
} finally {
229+
await owned.close();
230+
}
231+
});
232+
198233
it('TTL sweeper reaps expired repos', async () => {
199234
const shortLived = await startJss({
200235
plugins: [{

0 commit comments

Comments
 (0)