Skip to content

Commit 30e6856

Browse files
Ayush7614kevincodex1
authored andcommitted
fix(node): close quarantine bypass on encrypted blobs and visibility routes
Rebuilt per review: this branch now carries ONLY the quarantine fix (dropped the unrelated task-claim and Windows fetch commits, which live in #275/#312, and the author-local .cursor symlink). The ipfs.rs and db-layer halves of the original commit were superseded by main's paged scan, which already carries per-row quarantine flags; what remains is routing encrypted-blob and visibility handlers through authorize_repo_read so a quarantined repo is an opaque 404 there too, plus the three regression tests for exactly that surface.
1 parent bfc44f9 commit 30e6856

4 files changed

Lines changed: 335 additions & 65 deletions

File tree

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

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,24 @@ use axum::Json;
66
use crate::auth::AuthenticatedDid;
77
use crate::error::{AppError, Result};
88
use crate::state::AppState;
9-
use crate::visibility::{visibility_check, Decision};
109

1110
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs
1211
/// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who
1312
/// can read the repo. Not recipient-scoped: recipient identities are not stored,
1413
/// so access control here is repo readability and decryption is gated by the
1514
/// envelope crypto (only a real recipient can open an envelope).
15+
///
16+
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
17+
/// same as issues/changelogs — so a public-but-quarantined mirror cannot leak
18+
/// its encrypted blob index.
1619
pub async fn list_encrypted_blobs(
1720
State(state): State<AppState>,
1821
auth: Option<Extension<AuthenticatedDid>>,
1922
Path((owner, repo)): Path<(String, String)>,
2023
) -> Result<Json<serde_json::Value>> {
21-
let record = state
22-
.db
23-
.get_repo(&owner, &repo)
24-
.await?
25-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
2624
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
27-
let rules = state.db.list_visibility_rules(&record.id).await?;
28-
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
29-
{
30-
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
31-
}
25+
let (record, _rules) =
26+
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
3227
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
3328
let blobs: Vec<_> = rows
3429
.into_iter()
@@ -45,17 +40,9 @@ pub async fn get_encrypted_blob(
4540
auth: Option<Extension<AuthenticatedDid>>,
4641
Path((owner, repo, oid)): Path<(String, String, String)>,
4742
) -> Result<Vec<u8>> {
48-
let record = state
49-
.db
50-
.get_repo(&owner, &repo)
51-
.await?
52-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
5343
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
54-
let rules = state.db.list_visibility_rules(&record.id).await?;
55-
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
56-
{
57-
return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")));
58-
}
44+
let (record, _rules) =
45+
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
5946
let cid = state
6047
.db
6148
.encrypted_blob_cid(&record.id, &oid)
@@ -81,17 +68,9 @@ pub async fn replicate_encrypted_blobs(
8168
auth: Option<Extension<AuthenticatedDid>>,
8269
Path((owner, repo)): Path<(String, String)>,
8370
) -> Result<Json<serde_json::Value>> {
84-
let record = state
85-
.db
86-
.get_repo(&owner, &repo)
87-
.await?
88-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
8971
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
90-
let rules = state.db.list_visibility_rules(&record.id).await?;
91-
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
92-
{
93-
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
94-
}
72+
let (record, _rules) =
73+
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
9574
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
9675
let blobs: Vec<_> = rows
9776
.into_iter()

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,16 @@ mod authz_guard {
242242
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
243243
(protect, "protect_branch", "did_matches("),
244244
(protect, "unprotect_branch", "did_matches("),
245+
(visibility, "set_visibility", "authorize_repo_read("),
245246
(visibility, "set_visibility", "require_owner("),
247+
(visibility, "remove_visibility", "authorize_repo_read("),
246248
(visibility, "remove_visibility", "require_owner("),
249+
(visibility, "list_visibility", "authorize_repo_read("),
247250
(visibility, "list_visibility", "require_owner("),
248251
];
249252

250-
// The visibility rows prove require_owner is CALLED; this proves the helper
251-
// itself does DID-safe matching, not a raw/trailing-segment compare.
253+
// Rows above prove each visibility handler calls authorize_repo_read and
254+
// require_owner; this proves the helper itself does DID-safe matching.
252255
assert!(
253256
fn_body(visibility, "require_owner").contains("did_matches("),
254257
"visibility::require_owner must use did_matches for DID-safe owner matching"

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

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,10 @@ pub async fn set_visibility(
8585
Path((owner, repo)): Path<(String, String)>,
8686
Json(req): Json<SetVisibilityRequest>,
8787
) -> Result<(StatusCode, Json<serde_json::Value>)> {
88-
let record = state
89-
.db
90-
.get_repo(&owner, &repo)
91-
.await?
92-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
88+
// Quarantine first (via authorize_repo_read), then owner — same posture as
89+
// list_visibility so a quarantined repo cannot be mutated while reads 404.
90+
let (record, _rules) =
91+
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
9392
require_owner(&record, &auth.0)?;
9493
validate_path_glob(&req.path_glob)?;
9594

@@ -141,11 +140,8 @@ pub async fn remove_visibility(
141140
Path((owner, repo)): Path<(String, String)>,
142141
Json(req): Json<RemoveVisibilityRequest>,
143142
) -> Result<Json<serde_json::Value>> {
144-
let record = state
145-
.db
146-
.get_repo(&owner, &repo)
147-
.await?
148-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
143+
let (record, _rules) =
144+
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
149145
require_owner(&record, &auth.0)?;
150146

151147
state
@@ -171,14 +167,12 @@ pub async fn list_visibility(
171167
Extension(auth): Extension<AuthenticatedDid>,
172168
Path((owner, repo)): Path<(String, String)>,
173169
) -> Result<Json<serde_json::Value>> {
174-
let record = state
175-
.db
176-
.get_repo(&owner, &repo)
177-
.await?
178-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
170+
// Quarantine first (via authorize_repo_read), then owner — a quarantined
171+
// mirror must be opaque even to a caller matching owner_did.
172+
let (record, rules) =
173+
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
179174
require_owner(&record, &auth.0)?;
180175

181-
let rules = state.db.list_visibility_rules(&record.id).await?;
182176
let rules_json: Vec<_> = rules
183177
.into_iter()
184178
.map(|r| {
@@ -205,28 +199,18 @@ pub async fn list_visibility(
205199
/// denied one (`reinclude`), so a clean-clone client can sparse-exclude the
206200
/// denied subtrees while re-including the allowed nested paths. Unlike
207201
/// `list_visibility` this is not owner-gated and never exposes reader_dids.
202+
///
203+
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
204+
/// same posture as encrypted-blob discovery — so admission and private-subtree
205+
/// layout are not disclosed to anon, owner, or peers.
208206
pub async fn withheld_paths(
209207
State(state): State<AppState>,
210208
auth: Option<Extension<AuthenticatedDid>>,
211209
Path((owner, repo)): Path<(String, String)>,
212210
) -> Result<Json<serde_json::Value>> {
213-
let record = state
214-
.db
215-
.get_repo(&owner, &repo)
216-
.await?
217-
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
218-
219-
let rules = state.db.list_visibility_rules(&record.id).await?;
220211
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
221-
222-
// Whole-repo read gate: a caller who cannot read "/" gets repo-not-found,
223-
// matching the git read endpoints, so this never discloses a private repo's
224-
// existence or its path layout to an unauthorized caller.
225-
if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
226-
== crate::visibility::Decision::Deny
227-
{
228-
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
229-
}
212+
let (record, rules) =
213+
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
230214

231215
let withheld =
232216
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);

0 commit comments

Comments
 (0)