Skip to content

fix(visibility): gate repo-scoped read surfaces on visibility (#120)#157

Merged
kevincodex1 merged 17 commits into
Gitlawb:mainfrom
Gravirei:fix/issue-120-repo-scoped-read-visibility-gate
Jul 8, 2026
Merged

fix(visibility): gate repo-scoped read surfaces on visibility (#120)#157
kevincodex1 merged 17 commits into
Gitlawb:mainfrom
Gravirei:fix/issue-120-repo-scoped-read-visibility-gate

Conversation

@Gravirei

@Gravirei Gravirei commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Add authorize_repo_read visibility gates to 8 repo-scoped read handlers that previously only checked repo existence, leaking private-repo metadata to anonymous callers.

Motivation & context

Closes #120

Several repo-scoped read handlers verified the repo exists (get_repo) but never ran the visibility gate, so a private repo's ref certs, issues, labels, bounties, and star count were accessible without authentication.

Kind of change

  • Security fix

What changed

  • certs.rs: list_certs, get_cert — gate ref certificate visibility
  • issues.rs: list_issues, get_issue, list_issue_comments — gate issue visibility
  • labels.rs: list_labels — gate label visibility
  • bounties.rs: list_repo_bounties — gate bounty visibility (had no repo lookup at all)
  • stars.rs: get_star_status — gate star count visibility
  • api/mod.rs: removed gated handlers from known_ungated allowlist
  • Test coverage for all handlers (anon 404 / owner admitted)

How a reviewer can verify

cargo check -p gitlawb-node
cargo test -p gitlawb-node -- api::authz_guard

Before you request review

  • Scope is one logical change; no unrelated churn
  • New behavior is covered by tests

Summary by CodeRabbit

  • Bug Fixes

    • Strengthened private-repo visibility enforcement across repo-scoped read endpoints: certs, issues, issue comments, labels, repo bounties, and star count.
    • Anonymous callers are now denied with 404 Not Found, while authorized owners can access listing and fetch routes.
    • Cert access is more consistent: requesting a cert that doesn’t belong to the authorized repo results in 404.
  • Tests

    • Added an integration test suite validating private-repo gating behavior (including 404 for anonymous access).

Gravirei added 11 commits June 30, 2026 19:52
Gitlawb#126)

The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.
Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.
…tion

✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.

  ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.

  • P2 follow-up: Tree/commit disclosure tracked in Gitlawb#135 — out of scope here.
Copilot AI review requested due to automatic review settings July 6, 2026 10:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added the needs-tests Source changed without accompanying tests (advisory) label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Thanks for the contribution. A couple of things will help us review this faster:

  • This changes Rust source but no tests changed. Tests are required for fixes and strongly encouraged for features.

See CONTRIBUTING.md. Update the PR and these notes will clear automatically.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Repo-scoped read handlers for certs, issues, labels, bounties, and stars now accept optional authenticated callers and authorize via authorize_repo_read. The handler-gating allowlist was updated, and new private-repo tests cover anonymous denial and owner-signed access.

Changes

Visibility gating for repo-scoped read endpoints

Layer / File(s) Summary
Certs read endpoints gated by authorize_repo_read
crates/gitlawb-node/src/api/certs.rs
list_certs and get_cert accept optional auth, derive a caller when present, and use authorize_repo_read instead of direct repo lookup.
Issues read endpoints gated by authorize_repo_read
crates/gitlawb-node/src/api/issues.rs
list_issues, get_issue, and list_issue_comments derive a caller from optional auth and use authorize_repo_read; issue comments also verify the issue exists in the authorized repo.
Labels, bounties, and stars gated
crates/gitlawb-node/src/api/labels.rs, crates/gitlawb-node/src/api/bounties.rs, crates/gitlawb-node/src/api/stars.rs
list_labels, list_repo_bounties, and get_star_status now route through authorize_repo_read; stars.rs also updates its handler docs.
Allowlist update
crates/gitlawb-node/src/api/mod.rs
every_repo_scoped_handler_is_gated updates its known_ungated entries for repo-scoped handlers.
Visibility gating tests
crates/gitlawb-node/src/test_support.rs
New tests assert 404 for anonymous private-repo requests and success for owner-signed requests across certs, issues, labels, bounties, and stars.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • Gitlawb/node#52: Introduced the same authorize_repo_read gating pattern with Option<Extension<AuthenticatedDid>> across repo-scoped read handlers.
  • Gitlawb/node#122: Also updates the every_repo_scoped_handler_is_gated allowlist in crates/gitlawb-node/src/api/mod.rs.
  • Gitlawb/node#125: Changes authorize_repo_read behavior for quarantined repos, which directly affects the newly gated read paths.

Suggested labels: kind:security, subsystem:api, sev:high

Suggested reviewers: jatmn, kevincodex1, beardthelion

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding visibility gating to repo-scoped read surfaces.
Description check ✅ Passed The description matches the template well, with summary, motivation, change list, verification steps, and scope notes filled in.
Linked Issues check ✅ Passed The PR implements authorize_repo_read on all listed read handlers and adds tests for anonymous denial and owner access.
Out of Scope Changes check ✅ Passed The changes stay focused on visibility gating, allowlist cleanup, and matching tests; no unrelated churn is evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Gravirei Gravirei force-pushed the fix/issue-120-repo-scoped-read-visibility-gate branch from 0df4808 to fa3c67b Compare July 6, 2026 10:33

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/api/certs.rs (1)

48-55: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Scope the certificate lookup to the authorized repo.

Line 48 discards the authorized record, then Line 53 fetches by certificate id only. A caller who can read repo A could request /repos/A/certs/{cert_id_from_repo_B} and receive repo B certificate metadata. Validate cert.repo_id == record.id or add a repo-scoped DB lookup, returning the same 404 as a missing cert.

Proposed guard
-    let (_record, _rules) =
+    let (record, _rules) =
         crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;

     let cert = state
         .db
         .get_ref_certificate(&id)
         .await?
         .ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
+    if cert.repo_id != record.id {
+        return Err(AppError::NotFound(format!("certificate {id}")));
+    }

Based on learnings, access-denied and not-found cases should remain externally indistinguishable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/certs.rs` around lines 48 - 55, The certificate
lookup in `get_ref_certificate` is only using the certificate id and ignores the
authorized repo returned by `authorize_repo_read`, so it can expose metadata
from another repo. Update the cert retrieval path in `certs.rs` to scope the
lookup to the authorized `record` (either by adding a repo-scoped DB query or by
validating `cert.repo_id == record.id` before returning). If the cert is missing
or belongs to a different repo, return the same `AppError::NotFound` path used
for a missing certificate so access-denied and not-found remain
indistinguishable.

Source: Learnings

🧹 Nitpick comments (1)
crates/gitlawb-node/src/test_support.rs (1)

2394-2633: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the existing anon_get helper for anonymous requests.

These new tests hand-build anonymous GET requests via Request::builder()... inline, duplicating the already-defined anon_get(uri) helper (line 1113) used by earlier tests in this same file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/test_support.rs` around lines 2394 - 2633, The new
anonymous GET tests are duplicating request construction instead of using the
existing anon_get helper. Update the private-repo gate tests in test_support.rs
to call anon_get(...) for each anonymous GET request, matching the style already
used elsewhere in this file, and keep signed_request_as only for authenticated
cases. This should be applied across the certs, issues, labels, bounties, and
stars test cases while preserving the same URIs and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/api/issues.rs`:
- Around line 195-199: The issue is that the authorized repository result is
discarded and issue comments are fetched by issue_id alone, which can return
comments from a different repo. Update the issues handler in
authorize_repo_read/list_issue_comments flow to use the returned record (or a
repo-scoped comments query) to confirm the issue belongs to the authorized repo
before loading comments. Keep the same external behavior by returning a 404 for
missing or mismatched issues so access-denied and not-found remain
indistinguishable.

In `@crates/gitlawb-node/src/test_support.rs`:
- Around line 2439-2453: The owner-admission tests in
get_cert_gate_admits_owner_on_private, get_issue_gate_admits_owner_on_private,
and list_issue_comments_gate_admits_owner_on_private are only proving a 404
path, so they do not distinguish gate admission from gate denial. Update each
test to seed a real cert/issue/comment that the owner can successfully read,
then assert a success status (or otherwise confirm the handler returned the
resource) instead of relying on a nonexistent id. Keep the existing private-repo
setup and signed_request_as flow, but make the asserted outcome prove that
authorize_repo_read actually allowed the owner through.

---

Outside diff comments:
In `@crates/gitlawb-node/src/api/certs.rs`:
- Around line 48-55: The certificate lookup in `get_ref_certificate` is only
using the certificate id and ignores the authorized repo returned by
`authorize_repo_read`, so it can expose metadata from another repo. Update the
cert retrieval path in `certs.rs` to scope the lookup to the authorized `record`
(either by adding a repo-scoped DB query or by validating `cert.repo_id ==
record.id` before returning). If the cert is missing or belongs to a different
repo, return the same `AppError::NotFound` path used for a missing certificate
so access-denied and not-found remain indistinguishable.

---

Nitpick comments:
In `@crates/gitlawb-node/src/test_support.rs`:
- Around line 2394-2633: The new anonymous GET tests are duplicating request
construction instead of using the existing anon_get helper. Update the
private-repo gate tests in test_support.rs to call anon_get(...) for each
anonymous GET request, matching the style already used elsewhere in this file,
and keep signed_request_as only for authenticated cases. This should be applied
across the certs, issues, labels, bounties, and stars test cases while
preserving the same URIs and assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0620aa0f-40bb-4126-abe7-25356db3f429

📥 Commits

Reviewing files that changed from the base of the PR and between 6cff528 and 0df4808.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • crates/gitlawb-node/src/api/bounties.rs
  • crates/gitlawb-node/src/api/certs.rs
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/labels.rs
  • crates/gitlawb-node/src/api/mod.rs
  • crates/gitlawb-node/src/api/stars.rs
  • crates/gitlawb-node/src/test_support.rs
💤 Files with no reviewable changes (1)
  • crates/gitlawb-node/src/api/mod.rs

Comment thread crates/gitlawb-node/src/api/issues.rs Outdated
Comment thread crates/gitlawb-node/src/test_support.rs
@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior subsystem:attestation Certificates, anchoring, per-ref attestation subsystem:visibility Path-scoped visibility and content withholding labels Jul 6, 2026
@Gravirei Gravirei force-pushed the fix/issue-120-repo-scoped-read-visibility-gate branch 2 times, most recently from 20793cc to 9c46106 Compare July 6, 2026 10:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/gitlawb-node/src/test_support.rs (1)

2544-2578: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Bare repo at /tmp/<slug> not cleaned up.

init_bare writes to /tmp/<slug>/secret-repo.git but nothing removes it afterward, unlike advertisement_serves_real_refs_only_to_authorized_callers/ipfs_cid_* tests which wrap disk paths in a DirGuard. Same pattern repeats in get_issue_gate_admits_owner_on_private (Lines 2605-2651) and list_issue_comments_gate_admits_owner_on_private (Lines 2678-2733). Harmless for correctness (unique slugs per test) but accumulates stale dirs across CI runs.

♻️ Suggested cleanup pattern
-        let repo_dir = std::path::PathBuf::from("/tmp")
-            .join(&slug)
-            .join("secret-repo.git");
-        let _ = std::fs::remove_dir_all(&repo_dir);
-        std::fs::create_dir_all(repo_dir.parent().unwrap()).unwrap();
-        crate::git::store::init_bare(&repo_dir).unwrap();
+        let repo_dir = std::path::PathBuf::from("/tmp")
+            .join(&slug)
+            .join("secret-repo.git");
+        let _ = std::fs::remove_dir_all(&repo_dir);
+        std::fs::create_dir_all(repo_dir.parent().unwrap()).unwrap();
+        crate::git::store::init_bare(&repo_dir).unwrap();
+        let _guard = DirGuard(std::path::PathBuf::from("/tmp").join(&slug));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/test_support.rs` around lines 2544 - 2578, The test
helper in list_issues_gate_admits_owner_on_private leaves the bare repo under
/tmp/<slug> behind after init_bare, which should be cleaned up like the other
disk-backed tests. Wrap the repo path setup in a DirGuard-style cleanup or add
an equivalent teardown around the repo_dir used by init_bare, and apply the same
fix to the matching private-repo tests get_issue_gate_admits_owner_on_private
and list_issue_comments_gate_admits_owner_on_private so all temporary
/tmp/<slug> directories are removed automatically.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/gitlawb-node/src/test_support.rs`:
- Around line 2544-2578: The test helper in
list_issues_gate_admits_owner_on_private leaves the bare repo under /tmp/<slug>
behind after init_bare, which should be cleaned up like the other disk-backed
tests. Wrap the repo path setup in a DirGuard-style cleanup or add an equivalent
teardown around the repo_dir used by init_bare, and apply the same fix to the
matching private-repo tests get_issue_gate_admits_owner_on_private and
list_issue_comments_gate_admits_owner_on_private so all temporary /tmp/<slug>
directories are removed automatically.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea628843-6841-4e81-b74a-bf6be1d14976

📥 Commits

Reviewing files that changed from the base of the PR and between 20793cc and 9c46106.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • crates/gitlawb-node/src/api/bounties.rs
  • crates/gitlawb-node/src/api/certs.rs
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/labels.rs
  • crates/gitlawb-node/src/api/mod.rs
  • crates/gitlawb-node/src/api/stars.rs
  • crates/gitlawb-node/src/test_support.rs
💤 Files with no reviewable changes (1)
  • crates/gitlawb-node/src/api/mod.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/gitlawb-node/src/api/certs.rs
  • crates/gitlawb-node/src/api/labels.rs
  • crates/gitlawb-node/src/api/bounties.rs
  • crates/gitlawb-node/src/api/stars.rs
  • crates/gitlawb-node/src/api/issues.rs

@Gravirei Gravirei force-pushed the fix/issue-120-repo-scoped-read-visibility-gate branch from 9c46106 to 3ec0acc Compare July 6, 2026 11:09

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution. I do not see any actionable issues from my review.

@kevincodex1 LGTM

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gate additions on the 8 handlers are correct and the drift guard enforces them (I confirmed by removing get_cert's gate: every_repo_scoped_handler_is_gated fails). But the PR does not close #120 for the bounty class, and it locks the owner out of the one bounty endpoint it does gate.

Findings

  • [P1] Gate get_bounty and list_all_bounties — the #120 bounty leak is still open
    crates/gitlawb-node/src/api/bounties.rs:150 and :137
    list_repo_bounties is gated, but the same private-repo bounty metadata is served unauthenticated by GET /api/v1/bounties/{id} and GET /api/v1/bounties. Neither takes a caller or calls authorize_repo_read, and their route group bounty_read_routes (server.rs:240) has no optional_signature layer. create_bounty read-gates, so bounties legitimately sit on private repos carrying repo_owner/repo_name/issue_id/title/creator_did. I seeded a private repo with a bounty; an anonymous GET to both endpoints returned 200 with "repo_name":"secret-repo", the issue id, title, and creator DID. Resolve each bounty's repo and run authorize_repo_read at "/" before returning; drop unreadable rows in the list. The drift guard cannot see these because they key on a global id, not Path<(owner, repo)>.

  • [P2] list_repo_bounties denies the owner in production; the admit test masks it
    crates/gitlawb-node/src/server.rs:240
    bounty_read_routes is merged without optional_signature (and there is no global layer), so list_repo_bounties always sees auth = None and denies the owner on a private repo, not just anonymous callers. I confirmed with a real RFC-9421 signed owner request: 404 against the route with no layer (the production wiring), 200 with the layer added. The owner-admit test misses this because signed_request_as injects the AuthenticatedDid extension directly (test_support.rs:104) instead of routing through the middleware. Attach optional_signature to bounty_read_routes (or move the route into read_routes), and route the gate tests through the real middleware.

  • [P2] Add cross-repo IDOR regression tests for the two fixes that lack them
    crates/gitlawb-node/src/api/issues.rs:204 and crates/gitlawb-node/src/api/certs.rs:57
    The list_issue_comments issue-belongs-to-repo check and get_cert's cert.repo_id != record.id guard each close a real cross-repo IDOR but neither is covered. I removed the comments check and the whole issue test set still passed; get_cert's admit test uses a matching repo_id, so the != branch never runs. Add a repo-A-authorized / repo-B-resource-id test asserting 404 for each.

  • [P3] Cover the vacuous deny tests and skipped degenerate states
    crates/gitlawb-node/src/test_support.rs
    The get_cert/get_issue/list_issue_comments deny tests target a nonexistent id, so they 404 with or without the gate (I removed get_cert's gate and its deny test still passed); seed a matching-repo_id resource so the 404 comes only from the gate. Also untested: a quarantined repo, a public repo anon read (the gate must not over-deny), and owner bare-key vs full did:key (#153/#156).

The gating direction is right and the drift guard is a solid backstop; the bounty leak is the blocker. @kevincodex1 holding this until the bounty readers are gated.

@Gravirei Gravirei requested review from beardthelion and jatmn July 6, 2026 17:00

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gating direction is right and my prior blocker is resolved: all ten repo-scoped readers route through authorize_repo_read, the drift guard enforces the eight repo-scoped ones (I stripped get_cert's gate and both its deny test and the drift guard go red), is_owner normalizes without cross-method collision, and the deny tests seed real resources so they're load-bearing. Two things block, and the two new global-id gates ship untested.

Findings

  • [P1] Clamp limit and authorize each repo once in list_all_bounties — the diff makes an anonymous GET an unbounded DB scan
    crates/gitlawb-node/src/api/bounties.rs:137
    The handler fetches q.limit rows (unclamped: list_bounties binds it straight into LIMIT, db/mod.rs:2964) then calls authorize_repo_read per row, each running three sequential queries (get_repo + is_repo_quarantined + list_visibility_rules, mod.rs:45-57). The route carries only optional_signature and the outer layer is TraceLayer, no rate limit, so GET /api/v1/bounties?limit=<huge> costs 1 + 3N serial queries for an unauthenticated caller, N growable by anyone who can create_bounty. Pre-PR this was one query. A negative limit also reaches Postgres and 500s (confirmed). Clamp with the raw_limit.clamp(1, 200) pattern (repos.rs:313) and memoize the per-repo decision. Same clamp applies to list_repo_bounties.

  • [P2] Return the identical 404 body in get_bounty on an authz denial — it currently leaks the private repo's owner/name
    crates/gitlawb-node/src/api/bounties.rs:150
    get_bounty resolves the bounty first (NotFound("bounty {id} not found") when absent) then authorizes (RepoNotFound("{owner}/{name}") when private/quarantined). Both are 404 but the bodies differ — I drove it: a missing id returns {"error":"not_found",…} while an id on a private repo returns {"error":"repo_not_found","message":"repository 'did:key:…/secret-repo' not found"}, so an anonymous holder of a bounty id learns it maps to a private repo and reads that repo's owner/name. get_cert in this same PR already returns a byte-identical NotFound("certificate {id}") on both branches (certs.rs:55,58) — mirror that.

  • [P2] Add gate tests for get_bounty and list_all_bounties — the two new confidentiality branches have none
    crates/gitlawb-node/src/api/bounties.rs:137
    These are the global-id handlers authz_guard cannot see (it keys on Path<(owner, repo)>), so no automated backstop; a crate-wide grep finds zero tests for either. list_all_bounties's if authorize_repo_read(...).is_ok() filter is the only thing keeping private-repo bounties out of the anonymous global feed. Add an anon-deny test for get_bounty on a private repo and a list_all_bounties filter test (one private + one public bounty, assert anon sees only the public one), driven through build_router + a real signature like list_repo_bounties_gate_admits_owner_on_private (test_support.rs:2963).

  • [P3] list_issue_comments resolves an issue-id prefix for the existence check but fetches comments by the exact string
    crates/gitlawb-node/src/api/issues.rs:204
    The belongs-to guard calls git_issues::get_issue, which resolves an 8-char prefix to the full id, but the next line does list_issue_comments(&issue_id) with the raw param. I drove it: a prefix passes the gate then returns {"comments":[]} for an issue that has a comment, and an ambiguous prefix 500s instead of 404s. Bind the DB fetch to the id get_issue returns.

  • [P3] list_all_bounties filters after the limit, so a non-owner can get zero of many readable bounties
    crates/gitlawb-node/src/api/bounties.rs:137
    It fetches the newest limit rows then drops unreadable ones with no cursor. I drove it: with three newest private bounties and one older public bounty, an anon ?limit=3 returns {"bounties":[]} even though the public one is readable. Paginate by a stable cursor and filter before the page limit.

Separate follow-up issue (out of this PR's scope): the bounty mutation endpoints (claim/submit/approve/cancel/dispute) fetch by global id and return the full BountyRecord with no authorize_repo_read, so claim_bounty discloses a private repo's owner/name/title to any authenticated agent — the same leak class this PR closes on the read side.

@kevincodex1 holding for the amplification and the get_bounty error-channel.

@Gravirei Gravirei requested review from beardthelion and jatmn July 7, 2026 03:50

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates. I rechecked the current bounty/read-gate paths and found issues that still need to be addressed.

Findings

  • [P1] Do not treat a denied repo as allowed after the first bounty
    crates/gitlawb-node/src/api/bounties.rs:154
    The global bounty feed records only that a repo key has been seen, not whether authorize_repo_read admitted that repo. When the first bounty from a private repo is denied, seen.insert(key) still runs; the next bounty from that same private repo then hits seen.contains(&key) and is pushed into allowed without any authz check. An anonymous GET /api/v1/bounties can therefore leak every later bounty from a private repo that has more than one bounty in the fetched window. Please memoize the authorization result per repo, not just the repo key, and add a regression with two private bounties from the same repo.

  • [P2] Keep paging past hidden bounties instead of stopping at the first 1000 rows
    crates/gitlawb-node/src/api/bounties.rs:145
    The over-fetch fix still applies the SQL LIMIT before visibility filtering: with limit=200, the handler fetches at most the newest 1000 rows, filters private rows in memory, and never looks beyond that window. If those 1000 rows are private and an older public bounty exists, anonymous callers get an empty/short page even though readable bounties are available. Please page/scan with a bounded loop until the requested number of visible rows is collected, or otherwise move the visibility decision ahead of the page limit, and cover the hidden-window case in tests.

@beardthelion beardthelion dismissed stale reviews from themself July 7, 2026 04:55

Head advanced to 6fd29c9; superseded by my re-review on the current head.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jatmn already has the two I'd otherwise lead with, and I re-verified both by execution: the list_all_bounties memoize leak (P1, bounties.rs:154) still reproduces on this head (two bounties on one private repo, an anonymous GET /api/v1/bounties returns the second), and it clears with a per-repo decision cache; the paging window (P2, bounties.rs:145) is real too. The prior round's other fixes hold: get_bounty no longer leaks the repo identity on denial and the limit clamp kills the negative-limit 500, both confirmed by reverting them and watching the tests go red.

One blocker those reviews don't cover, plus a note on the paging fix.

Findings

  • [P2] Gate star_repo and unstar_repo on repo read, the same as get_star_status
    crates/gitlawb-node/src/api/stars.rs:17 and :52
    This PR gated the star-count read (get_star_status) and the module doc now says "Star count is gated on repo read access," but both write handlers still do a plain get_repo and then return star_count, confirm the repo exists, and insert or delete a star on a repo the caller cannot read. Driving a signed non-reader at a private repo: PUT /api/v1/repos/{owner}/{repo}/star returns 201 {"star_count":1,"status":"starred"} and DELETE returns 200, so any self-registered keypair gets an existence oracle, the star count, and an unauthorized write. Swap the get_repo lookup in both handlers for authorize_repo_read(&state, &owner, &repo, Some(caller), "/"); the non-reader then gets the same 404 as an absent repo, and the owner is still admitted (I checked both directions). The doc comment overclaims until this lands.

Note on the paging fix (jatmn's P2, bounties.rs:145)

A direction, not a measured claim: prefer moving the visibility filter into the query over a bounded scan-until-N-visible loop. list_all_bounties already over-fetches up to 1000 rows and runs one authorize_repo_read (three sequential queries) per distinct repo, so a single anonymous GET can cost on the order of 1000 fetched rows plus ~3000 serial authz queries. A loop that keeps fetching until it has collected the requested number of visible rows can amplify that further, whereas filtering to the caller's readable repos in SQL fixes the window and the cost together.

The gating direction is right and the drift guard is a good backstop. Out of scope for #120 but worth its own issue: the bounty mutation endpoints (claim, submit, approve, cancel, dispute) fetch by global id and return the full BountyRecord with no read gate, the same leak class on untouched write handlers.

@Gravirei Gravirei requested review from beardthelion and jatmn July 7, 2026 05:59

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates. I rechecked the current visibility-gated bounty paths and found issues that still need to be addressed.

Findings

  • [P1] Sign the CLI and MCP reads that now require repo visibility
    crates/gl/src/issue.rs:218, crates/gl/src/cert.rs:71, crates/gl/src/bounty.rs:196, crates/gl/src/repo.rs:661, crates/gl/src/star.rs:118, crates/gl/src/mcp.rs:973
    The server handlers now correctly use optional_signature plus authorize_repo_read, but the real clients for those reads were not updated to send a signature. gl issue list/show/comments, gl cert list/show, gl bounty list/show, gl bounty show, gl repo label-list, gl star count, and the issue section in gl status all build unsigned clients and call .get(...); the MCP tools build a client with the loaded keypair but still use unsigned .get(...) for the newly gated issue/bounty reads. As a result, the owner/authorized-reader cases covered by the server tests are not reachable through the shipped CLI/MCP for private repos: they arrive as anonymous and get the same 404 as a stranger. Please load the identity for these gated reads and call get_signed when a keypair is available, while keeping anonymous reads for public repos working.

  • [P2] Move global bounty visibility filtering ahead of the scan loop
    crates/gitlawb-node/src/api/bounties.rs:147
    The current fix follows the bounded scan-until-visible direction, but the loop still stops after scanning 10,000 rows and then returns whatever visible rows it found with no cursor, has_more, or incomplete marker. With 10,000 newer hidden bounties and an older public bounty, an anonymous GET /api/v1/bounties?limit=1 still returns a complete-looking empty page even though a readable bounty exists; the new regression covers six hidden rows, not the capped path. Please push the readable-repo predicate into the query, or otherwise filter before applying the page limit, so the endpoint cannot present partial results as complete. If the implementation keeps a bounded scan loop instead, return an explicit incomplete/failure state when the cap is reached before filling the requested page.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jatmn's two findings hold and I verified both: the unsigned CLI/MCP reads (NodeClient::new(node, None) + .get(), so an authorized owner arrives anonymous and 404s on their own private repo; get_signed already exists at crates/gl/src/http.rs:50), and the capped-scan completeness gap. The read-surface core is solid and I confirmed it by execution: the memoize decision-cache, the multi-page loop, and the star/unstar gate each go RED when neutralized and GREEN on this head, and every BountyRecord reader is gated (get_bounty 404, list_repo/list_all per-row) or aggregate-only.

Two things jatmn's findings don't cover:

Findings

  • [P2] Gate claim_bounty on repo read
    crates/gitlawb-node/src/api/bounties.rs:211
    A permissionless signed non-reader POSTing /api/v1/bounties/{id}/claim on a private repo's open bounty gets a 200 with the full record (repo_owner/repo_name/title/creator_did) and becomes its claimant. It is the same data class this PR closes on the read side, reached through a mutation; pre-existing rather than introduced here, but it belongs with the rest of #120. Mirror get_bounty: on authorize_repo_read(...).is_err() return NotFound("bounty {id} not found"). A bare authorize_repo_read(...)? is not enough on its own; its RepoNotFound body still echoes owner/repo.

  • [P2] Fix the OFFSET paging instability while reworking the scan
    crates/gitlawb-node/src/api/bounties.rs:135, crates/gitlawb-node/src/db/mod.rs:2959
    Separate from jatmn's cap point: list_bounties orders by created_at DESC with no id tie-break, and the new loop pages by growing OFFSET, so an ordinary concurrent insert shifts every later offset and a readable bounty is duplicated or skipped across the page boundary (reproduced: page 1 [b00..b09], page 2 [b09, ...]). The same loop is also an anonymous cost lever: no rate limiter, up to max_rows=10_000 rows scanned via deep OFFSET plus up to ~10k authorize_repo_read calls per request. The repo already has the pattern that fixes cap-completeness, instability, and cost together: list_ref_updates_keyset ((timestamp, id) < (after_ts, after_id), ORDER BY timestamp DESC, id DESC). Reusing it resolves this and jatmn's P2 in one change.

Once claim is gated and the feed moves to the keyset cursor, this is close.

@beardthelion beardthelion dismissed their stale review July 7, 2026 13:49

Superseded by my re-review on 119bfb1 (the memoize/paging/star fixes are verified load-bearing here); dismissing so my state reflects the current head.

@Gravirei Gravirei requested review from beardthelion and jatmn July 7, 2026 15:12

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates. I rechecked the previously discussed paths and do not see any remaining actionable issues from my side.

@beardthelion LGTM

@beardthelion beardthelion dismissed their stale review July 7, 2026 16:03

Superseded by re-review of the new head 0be5e42.

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the new head 0be5e42 against my earlier blockers and jatmn's. All five are resolved, and I verified each by execution against a local Postgres rather than by reading.

  • claim_bounty now gates on authorize_repo_read before the status check and the mutation, and maps an authz failure to the same bounty {id} not found so the body carries no repo path (bounties.rs:245). A signed non-reader gets 404 with no owner/name/title and no status flip; reverting the gate turns that into 200 plus a real claim, so the gate is load-bearing.
  • list_all_bounties moved from OFFSET to a (created_at, id) keyset and the scan cap now returns 422 Incomplete instead of a complete-looking partial page (bounties.rs:158, db/mod.rs). Confirmed the keyset is stable across a page boundary under a concurrent insert where OFFSET duplicated a row, and that a feed with 10k+ hidden rows returns 422, not a partial 200.
  • ?limit is clamped on both bounty read handlers; a negative limit no longer reaches Postgres as LIMIT -1 (500).
  • The gated reads now sign via get_authed, so an owner reaches their own private-repo bounty (signed 200) where an anon gets 404. The net-new gates in this PR (bounties/certs/issues/labels/stars) all have their CLI/MCP clients converted, and the drift guard was tightened correctly (the eight known_ungated entries removed now that their gates landed).

Non-blocking follow-ups, none of which should hold this PR:

  • The list-style clients (bounty/issue/cert list, label-list, issue comments) parse the body without checking resp.status(), so a transient node 5xx renders as "No X" and exits 0. The single-item show paths already check status; worth aligning the list paths.
  • signed_client uses load_keypair_from_dir(dir).ok(), which collapses "no identity" (fine, unsigned public read) and "identity present but unreadable" (a real error) into an unsigned request. On a caller-gated read the second case surfaces as a false "not found" rather than an identity error.
  • The scan cap can return 422 for an honest query on a very large feed when the caller's visible rows sit past the window. Exposing the internal keyset cursor as a client page param would let those queries page instead.
  • Two behaviors here ship without a test: the claim_bounty gate and the 422 cap path. Both are quick to add.

@kevincodex1 good to merge once you're happy with the above.

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch

@kevincodex1 kevincodex1 merged commit 26bc3f6 into Gitlawb:main Jul 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior needs-tests Source changed without accompanying tests (advisory) subsystem:attestation Certificates, anchoring, per-ref attestation subsystem:visibility Path-scoped visibility and content withholding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repo-scoped read surfaces gated by existence only, not visibility (certs, issues, labels, bounties, stars)

5 participants