Skip to content

Commit 3f494e9

Browse files
author
t
committed
fix(node): gate commit/tag objects by reachability in /ipfs/{cid} (#173, F2)
The full-scan pin path deliberately pins dangling (unreachable) non-blob objects, but the CID resolver only proved reachability for blobs and trees; commit and tag objects fell through to serve. A dangling commit/tag in a path-scoped repo could therefore be served anonymously by CID, leaking its message and structural metadata. Add reachable_commit_tag_oids (reachable commits via rev-list --all UNION annotated-tag objects at refs) and gate commit/tag under a path-scoped rule against it, exactly as blob/tree are gated against their allowed-sets. The three walks are unified into one cap+per-walk-quota path, so commit/tag reachability walks share the fan-out ceiling and IP quota and cannot bypass them (R6). RED->GREEN: ipfs_cid_dangling_commit_and_tag_fail_closed_under_path_rules (a dangling commit AND annotated tag, sentinel messages, must 404 for anon+owner with no leak; served 200 + sentinel before the fix). Reachable commit/tag still serve: ipfs_cid_gate_withholds_blob_from_unauthorized stays green. ipfs_walk_commit_tag_ candidate_respects_the_walk_cap (was ipfs_walk_cap_still_serves_walk_free_candidate) proves commit/tag walks respect the cap. Full node (508) + gl (268) suites green.
1 parent bf91410 commit 3f494e9

3 files changed

Lines changed: 248 additions & 100 deletions

File tree

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

Lines changed: 71 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use crate::error::{AppError, Result};
3232
use crate::git::store;
3333
use crate::git::visibility_pack::{
3434
allowed_blob_set_for_caller, allowed_tree_set_for_caller, has_path_scoped_rule,
35+
reachable_commit_tag_oids,
3536
};
3637
use crate::state::AppState;
3738
use crate::visibility::{visibility_check, Decision};
@@ -64,15 +65,16 @@ pub(crate) const MAX_HISTORY_WALKS_PER_REQUEST: u32 = 16;
6465
/// row than the one read (KTD2a). We check object existence via
6566
/// `store::object_type` *before* the expensive reachability walk so random-CID
6667
/// spray cannot trigger full-history git walks on repos that don't carry the
67-
/// object. When the row carries path-scoped rules (KTD4) the served object must
68-
/// be either a `commit`/`tag` (root-level metadata the caller already cleared the
69-
/// `"/"` gate for) OR a `blob`/`tree` in the caller's *reachable* allowed-set
70-
/// (`allowed_blob_set_for_caller` / `allowed_tree_set_for_caller`). A withheld
71-
/// subtree's tree object is denied here exactly as `get_tree` denies its path, so
72-
/// its child names and oids cannot leak by CID (#135). The reachable allowed-sets
73-
/// exclude dangling objects — a blob or tree written via plumbing and never
74-
/// committed has no path to gate, so it is fail-closed 404'd under path-scoped
75-
/// rules (#126). Denial and genuine not-found both fall through to an opaque 404.
68+
/// object. When the row carries path-scoped rules (KTD4) the served object is
69+
/// gated by type: a `blob`/`tree` must be in the caller's *reachable* allowed-set
70+
/// (`allowed_blob_set_for_caller` / `allowed_tree_set_for_caller`), and a
71+
/// `commit`/`tag` must be in the repo's *reachable* commit/tag set
72+
/// (`reachable_commit_tag_oids`, #173). A withheld subtree's tree object is denied
73+
/// here exactly as `get_tree` denies its path, so its child names and oids cannot
74+
/// leak by CID (#135). All these sets exclude dangling objects — a blob, tree,
75+
/// commit, or tag written via plumbing and never referenced has no reachable path,
76+
/// so it is fail-closed 404'd under path-scoped rules (#126, #173). Denial and
77+
/// genuine not-found both fall through to an opaque 404.
7678
///
7779
/// Scope: this closes the direct unauthenticated scan, including the dangling
7880
/// case. A stale-public mirror row still serves withheld content (tracked
@@ -175,6 +177,11 @@ pub async fn get_by_cid(
175177
// it. Built lazily and only for a tree fetch (a request is one CID = one object
176178
// type), so one request builds exactly one of the two sets — no double walk.
177179
let mut allowed_tree_memo: HashMap<String, HashSet<String>> = HashMap::new();
180+
// The reachable commit/tag set (#173, F2): a dangling commit/tag is in no
181+
// repo's set, so it fails closed under a path-scoped rule. Caller-independent
182+
// (reachability is objective), so keyed on repo.id alone. Built lazily on a
183+
// commit/tag fetch, sharing the walk cap + IP quota with the blob/tree walks.
184+
let mut reachable_ct_memo: HashMap<String, HashSet<String>> = HashMap::new();
178185

179186
// The `/ipfs/{cid}` route is anonymous and a valid tree CID makes each
180187
// path-scoped repo pay a fresh full-history allowed-set walk, memoized only
@@ -234,30 +241,37 @@ pub async fn get_by_cid(
234241

235242
// Per-object gating applies only when a path-scoped rule exists (KTD4);
236243
// without one, the "/" gate above is the whole story. Under a path-scoped
237-
// rule a `blob` is gated against the caller's allowed-blob-set and a `tree`
238-
// against the allowed-tree-set (#135 — a withheld subtree's tree structure
239-
// must not leak by CID where get_tree protects it). A `commit`/`tag` exposes
240-
// only "/"-level metadata the caller already cleared the "/" gate for, so it
241-
// falls through to serve.
244+
// rule the fetched object is gated by type: a `blob` against the caller's
245+
// allowed-blob-set, a `tree` against the allowed-tree-set (#135 — a
246+
// withheld subtree's structure must not leak by CID), and a `commit`/`tag`
247+
// against the repo's REACHABLE commit/tag set (#173, F2 — a dangling
248+
// commit/tag has no authorized reachable path, so it must not serve its
249+
// message/metadata; a reachable one already cleared the "/" gate). Each
250+
// set is a full-history git walk, so all three share the per-request cap
251+
// and per-walk IP quota below.
242252
let path_scoped = has_path_scoped_rule(rules);
243-
if path_scoped && (obj_type == "blob" || obj_type == "tree") {
244-
let is_blob = obj_type == "blob";
245-
let memo = if is_blob {
246-
&mut allowed_blob_memo
247-
} else {
248-
&mut allowed_tree_memo
253+
let gated =
254+
path_scoped && matches!(obj_type.as_str(), "blob" | "tree" | "commit" | "tag");
255+
if gated {
256+
// One memo per kind: blob/tree gate on the caller's allowed-set,
257+
// commit/tag on reachability (caller-independent — reachability is
258+
// objective, so the set is not keyed on the caller).
259+
let memo = match obj_type.as_str() {
260+
"blob" => &mut allowed_blob_memo,
261+
"tree" => &mut allowed_tree_memo,
262+
_ => &mut reachable_ct_memo,
249263
};
250264
if !memo.contains_key(&repo.id) {
251265
// Per-request fan-out ceiling (INV-10): once this request has
252266
// spawned MAX_HISTORY_WALKS_PER_REQUEST walks, skip THIS
253267
// walk-requiring candidate and keep scanning (#173, F4). A plain
254268
// `continue`, not `break`: a later candidate servable WITHOUT a
255-
// walk (a `commit`/`tag`, or a no-rule public copy) must still be
256-
// reached and served — breaking the loop would 404 that publicly
257-
// readable content for no amplification benefit. `walks` is only
258-
// incremented inside this walk block, so walk-free candidates
259-
// never consume the budget and the ceiling still bounds spawned
260-
// walks to MAX_HISTORY_WALKS_PER_REQUEST.
269+
// walk (a walk-free reachable copy, or a no-rule public copy) must
270+
// still be reached and served — breaking the loop would 404 that
271+
// publicly readable content for no amplification benefit. `walks`
272+
// is only incremented inside this walk block, so walk-free
273+
// candidates never consume the budget and the ceiling still bounds
274+
// spawned walks to MAX_HISTORY_WALKS_PER_REQUEST.
261275
if walks >= MAX_HISTORY_WALKS_PER_REQUEST {
262276
continue;
263277
}
@@ -270,15 +284,13 @@ pub async fn get_by_cid(
270284
// charged. When no key resolves (e.g. a test `oneshot` with no
271285
// peer and no trusted header) the limiter is skipped, exactly as
272286
// the other IP brakes behave.
287+
if let Some(key) =
288+
crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust)
273289
{
274-
if let Some(key) =
275-
crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust)
276-
{
277-
if !state.ipfs_rate_limiter.check(&key).await {
278-
return Err(AppError::TooManyRequests(
279-
"ipfs retrieval rate limit exceeded — try again later".into(),
280-
));
281-
}
290+
if !state.ipfs_rate_limiter.check(&key).await {
291+
return Err(AppError::TooManyRequests(
292+
"ipfs retrieval rate limit exceeded — try again later".into(),
293+
));
282294
}
283295
}
284296

@@ -287,32 +299,32 @@ pub async fn get_by_cid(
287299
let is_public = repo.is_public;
288300
let owner = repo.owner_did.clone();
289301
let caller_for_walk = caller_owned.clone();
290-
// Full-history walk shells out to git — keep it off the async runtime.
291-
// Only the fetched object's type is walked (blob XOR tree), so a tree
292-
// fetch never pays the blob walk and vice-versa.
293-
let walk = tokio::task::spawn_blocking(move || {
294-
if is_blob {
295-
allowed_blob_set_for_caller(
296-
&rp,
297-
&r,
298-
is_public,
299-
&owner,
300-
caller_for_walk.as_deref(),
301-
)
302-
} else {
303-
allowed_tree_set_for_caller(
304-
&rp,
305-
&r,
306-
is_public,
307-
&owner,
308-
caller_for_walk.as_deref(),
309-
)
310-
}
302+
let kind = obj_type.clone();
303+
// Full-history walk shells out to git — keep it off the async
304+
// runtime. Only the fetched object's type is walked.
305+
let walk = tokio::task::spawn_blocking(move || match kind.as_str() {
306+
"blob" => allowed_blob_set_for_caller(
307+
&rp,
308+
&r,
309+
is_public,
310+
&owner,
311+
caller_for_walk.as_deref(),
312+
),
313+
"tree" => allowed_tree_set_for_caller(
314+
&rp,
315+
&r,
316+
is_public,
317+
&owner,
318+
caller_for_walk.as_deref(),
319+
),
320+
// commit | tag: caller-independent reachable set.
321+
_ => reachable_commit_tag_oids(&rp),
311322
})
312323
.await;
313324
// Fail closed on EITHER a task panic (JoinError) or a walk error:
314-
// we cannot prove the caller may read here, so skip this repo and
315-
// let a public copy (if any) serve. Never serve on an unproven gate.
325+
// we cannot prove the object is readable here, so skip this repo
326+
// and let a public copy (if any) serve. Never serve on an unproven
327+
// gate.
316328
let set = match walk {
317329
Ok(Ok(set)) => set,
318330
Ok(Err(e)) => {
@@ -326,10 +338,10 @@ pub async fn get_by_cid(
326338
};
327339
memo.insert(repo.id.clone(), set);
328340
}
329-
let in_allowed = memo
341+
let in_set = memo
330342
.get(&repo.id)
331343
.is_some_and(|set| set.contains(sha256_hex));
332-
if !in_allowed {
344+
if !in_set {
333345
continue;
334346
}
335347
}

‎crates/gitlawb-node/src/git/visibility_pack.rs‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
//! denied. Since #135 a tree is likewise withheld on the CID serve surface
44
//! (`GET /ipfs/{cid}`) when every path at which it appears is denied, via the
55
//! parallel `allowed_tree_set_for_caller` — so a withheld subtree's structure does
6-
//! not leak by content address. Commits and tags are never withheld here, and
7-
//! trees still replicate freely on the pin path (the withheld-tree pin gap is
8-
//! tracked as #172). Mode B keeps all object SHAs intact; only readability is gated.
6+
//! not leak by content address. Since #173 a commit or tag is likewise gated on the
7+
//! CID serve surface: a DANGLING (unreachable) commit/tag is withheld under a
8+
//! path-scoped rule via `reachable_commit_tag_oids`, so its message/metadata does
9+
//! not leak by content address; a reachable one still serves. Trees still replicate
10+
//! freely on the pin path (the withheld-tree pin gap is tracked as #172). Mode B
11+
//! keeps all object SHAs intact; only readability is gated.
912
1013
use crate::db::VisibilityRule;
1114
use crate::git::store;
@@ -161,6 +164,36 @@ fn reachable_commits(repo_path: &Path) -> Result<Vec<String>> {
161164
.collect())
162165
}
163166

167+
/// Every reachable commit oid PLUS every annotated-tag OBJECT oid at a ref (#173,
168+
/// F2). `reachable_commits` (`rev-list --all`) dereferences annotated tags to their
169+
/// commits, so the tag objects themselves are absent; a CID for a REACHABLE tag
170+
/// object must still serve, so union the tag-object oids from the refs. A DANGLING
171+
/// commit/tag (referenced by no ref) is in neither set, so the `/ipfs/{cid}`
172+
/// resolver denies it under a path-scoped rule instead of leaking its message.
173+
/// Nested tag-of-tag inner objects are over-denied (safe: availability, not a leak
174+
/// — the outer ref tag is still served). Fails closed on any git error.
175+
pub fn reachable_commit_tag_oids(repo_path: &Path) -> Result<HashSet<String>> {
176+
let mut set: HashSet<String> = reachable_commits(repo_path)?.into_iter().collect();
177+
let out = std::process::Command::new("git")
178+
.args(["for-each-ref", "--format=%(objectname) %(objecttype)"])
179+
.current_dir(repo_path)
180+
.output()
181+
.context("git for-each-ref (tag objects) failed")?;
182+
if !out.status.success() {
183+
anyhow::bail!(
184+
"git for-each-ref failed: {}",
185+
String::from_utf8_lossy(&out.stderr)
186+
);
187+
}
188+
for line in String::from_utf8_lossy(&out.stdout).lines() {
189+
let mut it = line.split_whitespace();
190+
if let (Some(oid), Some("tag")) = (it.next(), it.next()) {
191+
set.insert(oid.to_string());
192+
}
193+
}
194+
Ok(set)
195+
}
196+
164197
/// Every `(oid, "/repo/relative/path", kind)` triple reachable from any commit in
165198
/// `repo_path` — the single walk seam that `blob_paths` (`kind == "blob"`) and
166199
/// `tree_paths` (`kind == "tree"`) both filter, so the blob and tree visibility

0 commit comments

Comments
 (0)