Skip to content

Commit 2fd72d0

Browse files
kevincodex1OpenClaude
andcommitted
fix(security): reject path traversal in repo_store local_path
Closes CodeQL alert #4 (Uncontrolled data used in path expression). Both `owner_did` and `repo_name` come from URL parameters and were used unsanitized to build a filesystem path, so a request like `/did:key:foo/../../../etc/passwd.git/info/refs` could escape the repos directory. Fix: - New `validate_owner_did` and `validate_repo_name` enforce a strict allowlist before path construction (alphanumeric + `: . _ -` for DIDs; alphanumeric + `. _ -` for repo names; rejects empty, `..`, leading `.` or `-`, slashes, backslashes, null bytes, overlong inputs). - `RepoStore::local_path` now returns `Result<…>` and the 5 callers propagate the error. - Defence in depth: even after the allowlist passes, the joined path is checked to still be rooted at `repos_dir`. - 17 unit tests cover normal DIDs/repo names plus every malicious shape (`..`, `/`, `\`, leading dot/dash, null byte, overlong, did:web with dots). Co-Authored-By: OpenClaude <openclaude@gitlawb.com>
1 parent 196c83d commit 2fd72d0

1 file changed

Lines changed: 246 additions & 7 deletions

File tree

crates/gitlawb-node/src/git/repo_store.rs

Lines changed: 246 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ impl RepoStore {
4747
/// spawned to lazily migrate it (on-demand migration for pre-Tigris repos).
4848
/// Returns the local path to the bare repo.
4949
pub async fn acquire(&self, owner_did: &str, repo_name: &str) -> Result<PathBuf> {
50-
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
50+
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
5151

5252
// Fast path: repo exists locally
5353
if local_path.exists() {
@@ -115,7 +115,7 @@ impl RepoStore {
115115
/// `git-receive-pack`) so the client sees the same refs that `acquire_write()`
116116
/// will operate on.
117117
pub async fn acquire_fresh(&self, owner_did: &str, repo_name: &str) -> Result<PathBuf> {
118-
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
118+
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
119119

120120
if let Some(ref tigris) = self.tigris {
121121
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
@@ -135,7 +135,7 @@ impl RepoStore {
135135
/// Take a write lock (Postgres advisory lock), ensure repo is local, return guard.
136136
/// The lock prevents concurrent writes to the same repo across machines.
137137
pub async fn acquire_write(&self, owner_did: &str, repo_name: &str) -> Result<RepoWriteGuard> {
138-
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
138+
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
139139
let lock_key = advisory_lock_key(&owner_slug, repo_name);
140140

141141
// Acquire Postgres advisory lock with retry using pg_try_advisory_lock
@@ -183,7 +183,7 @@ impl RepoStore {
183183

184184
/// Initialize a new bare repo on local disk and upload to Tigris.
185185
pub async fn init(&self, owner_did: &str, repo_name: &str) -> Result<PathBuf> {
186-
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
186+
let (owner_slug, local_path) = self.local_path(owner_did, repo_name)?;
187187

188188
store::init_bare(&local_path).context("initializing bare repo")?;
189189

@@ -207,22 +207,98 @@ impl RepoStore {
207207
/// Call this after any operation that modifies the git repo on disk.
208208
pub async fn release_after_write(&self, owner_did: &str, repo_name: &str) {
209209
if let Some(ref tigris) = self.tigris {
210-
let (owner_slug, local_path) = self.local_path(owner_did, repo_name);
210+
let (owner_slug, local_path) = match self.local_path(owner_did, repo_name) {
211+
Ok(p) => p,
212+
Err(e) => {
213+
warn!(repo = %repo_name, err = %e, "rejected unsafe path in release_after_write");
214+
return;
215+
}
216+
};
211217
if let Err(e) = tigris.upload(&owner_slug, repo_name, &local_path).await {
212218
warn!(repo = %repo_name, err = %e, "failed to upload repo to tigris after write");
213219
}
214220
}
215221
}
216222

217223
/// Compute the local disk path and owner slug for a repo.
218-
fn local_path(&self, owner_did: &str, repo_name: &str) -> (String, PathBuf) {
224+
///
225+
/// Rejects any input that could be used for path traversal — strict
226+
/// allowlist on both `owner_did` and `repo_name`, length-bounded, and
227+
/// the resulting path must remain inside `repos_dir`.
228+
fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> {
229+
validate_path_components(owner_did, repo_name)?;
230+
219231
let owner_slug = owner_did.replace(':', "_").replace('/', "_");
220232
let local_path = self
221233
.repos_dir
222234
.join(&owner_slug)
223235
.join(format!("{repo_name}.git"));
224-
(owner_slug, local_path)
236+
237+
// Defence in depth: even though both inputs are allowlisted, verify
238+
// the joined path is still rooted at repos_dir.
239+
if !local_path.starts_with(&self.repos_dir) {
240+
anyhow::bail!(
241+
"computed repo path escaped repos_dir: {}",
242+
local_path.display()
243+
);
244+
}
245+
246+
Ok((owner_slug, local_path))
247+
}
248+
}
249+
250+
/// Strict allowlist validator for `owner_did` and `repo_name`.
251+
///
252+
/// Rejects any character that isn't explicitly safe, plus length and
253+
/// special-sequence checks (`..`, leading `.`, leading `-`).
254+
fn validate_path_components(owner_did: &str, repo_name: &str) -> Result<()> {
255+
validate_owner_did(owner_did)?;
256+
validate_repo_name(repo_name)?;
257+
Ok(())
258+
}
259+
260+
fn validate_owner_did(owner_did: &str) -> Result<()> {
261+
if owner_did.is_empty() {
262+
anyhow::bail!("owner_did is empty");
263+
}
264+
if owner_did.len() > 256 {
265+
anyhow::bail!("owner_did exceeds 256 chars");
266+
}
267+
// DIDs are `did:method:identifier` — `did:key:z6Mk...`, `did:web:host:user`, etc.
268+
// Allow alnum + `:`, `.`, `_`, `-`. Reject `..` substring and any `/` or `\`.
269+
if owner_did.contains("..") {
270+
anyhow::bail!("owner_did contains '..' sequence");
271+
}
272+
for ch in owner_did.chars() {
273+
let ok = ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-');
274+
if !ok {
275+
anyhow::bail!("owner_did contains disallowed character: {ch:?}");
276+
}
277+
}
278+
Ok(())
279+
}
280+
281+
fn validate_repo_name(repo_name: &str) -> Result<()> {
282+
if repo_name.is_empty() {
283+
anyhow::bail!("repo_name is empty");
284+
}
285+
if repo_name.len() > 100 {
286+
anyhow::bail!("repo_name exceeds 100 chars");
225287
}
288+
// Repo names are `[A-Za-z0-9._-]+` minus path-traversal traps.
289+
if repo_name.contains("..") {
290+
anyhow::bail!("repo_name contains '..' sequence");
291+
}
292+
if repo_name.starts_with('.') || repo_name.starts_with('-') {
293+
anyhow::bail!("repo_name must not start with '.' or '-'");
294+
}
295+
for ch in repo_name.chars() {
296+
let ok = ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-');
297+
if !ok {
298+
anyhow::bail!("repo_name contains disallowed character: {ch:?}");
299+
}
300+
}
301+
Ok(())
226302
}
227303

228304
/// Guard returned by `acquire_write()`. Holds the Postgres advisory lock and
@@ -270,3 +346,166 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
270346
repo_name.hash(&mut hasher);
271347
hasher.finish() as i64
272348
}
349+
350+
#[cfg(test)]
351+
mod tests {
352+
use super::*;
353+
354+
// ── repo_name validation ───────────────────────────────────────────────
355+
356+
#[test]
357+
fn repo_name_accepts_normal_names() {
358+
for name in [
359+
"hello",
360+
"hello-world",
361+
"hello_world",
362+
"hello.world",
363+
"Repo123",
364+
"a",
365+
] {
366+
validate_repo_name(name).unwrap_or_else(|e| panic!("{name} should be valid: {e}"));
367+
}
368+
}
369+
370+
#[test]
371+
fn repo_name_rejects_empty() {
372+
assert!(validate_repo_name("").is_err());
373+
}
374+
375+
#[test]
376+
fn repo_name_rejects_path_traversal_dotdot() {
377+
for name in ["..", "../etc", "../../passwd", "foo/../bar", "a..b"] {
378+
assert!(
379+
validate_repo_name(name).is_err(),
380+
"{name:?} must be rejected"
381+
);
382+
}
383+
}
384+
385+
#[test]
386+
fn repo_name_rejects_slashes() {
387+
for name in ["foo/bar", "foo\\bar", "/abs", "a/b/c"] {
388+
assert!(
389+
validate_repo_name(name).is_err(),
390+
"{name:?} must be rejected"
391+
);
392+
}
393+
}
394+
395+
#[test]
396+
fn repo_name_rejects_leading_dot_or_dash() {
397+
for name in [".hidden", ".", "-foo"] {
398+
assert!(
399+
validate_repo_name(name).is_err(),
400+
"{name:?} must be rejected"
401+
);
402+
}
403+
}
404+
405+
#[test]
406+
fn repo_name_rejects_null_byte() {
407+
assert!(validate_repo_name("foo\0bar").is_err());
408+
}
409+
410+
#[test]
411+
fn repo_name_rejects_overlong() {
412+
let long = "a".repeat(101);
413+
assert!(validate_repo_name(&long).is_err());
414+
}
415+
416+
// ── owner_did validation ───────────────────────────────────────────────
417+
418+
#[test]
419+
fn owner_did_accepts_did_key() {
420+
validate_owner_did("did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr").unwrap();
421+
}
422+
423+
#[test]
424+
fn owner_did_accepts_did_web_with_dots() {
425+
validate_owner_did("did:web:example.com:user").unwrap();
426+
}
427+
428+
#[test]
429+
fn owner_did_rejects_empty() {
430+
assert!(validate_owner_did("").is_err());
431+
}
432+
433+
#[test]
434+
fn owner_did_rejects_path_traversal() {
435+
for did in [
436+
"did:key:..",
437+
"did:key:../../etc",
438+
"..",
439+
"did:key:foo/../bar",
440+
] {
441+
assert!(validate_owner_did(did).is_err(), "{did:?} must be rejected");
442+
}
443+
}
444+
445+
#[test]
446+
fn owner_did_rejects_slashes_and_backslashes() {
447+
for did in ["did:key:foo/bar", "did:key:foo\\bar", "did/key/foo"] {
448+
assert!(validate_owner_did(did).is_err(), "{did:?} must be rejected");
449+
}
450+
}
451+
452+
#[test]
453+
fn owner_did_rejects_null_byte() {
454+
assert!(validate_owner_did("did:key:z6Mk\0evil").is_err());
455+
}
456+
457+
#[test]
458+
fn owner_did_rejects_overlong() {
459+
let long = format!("did:key:{}", "z".repeat(260));
460+
assert!(validate_owner_did(&long).is_err());
461+
}
462+
463+
// ── end-to-end local_path ──────────────────────────────────────────────
464+
465+
fn make_store() -> RepoStore {
466+
// We only exercise the path-construction code, which doesn't touch
467+
// the pool or the network. Fabricate a pool reference via PgPool::connect_lazy
468+
// so we don't need a live DB.
469+
let pool = sqlx::PgPool::connect_lazy("postgres://invalid").unwrap();
470+
RepoStore::new(PathBuf::from("/var/lib/gitlawb/repos"), None, pool)
471+
}
472+
473+
#[tokio::test]
474+
async fn local_path_resolves_safe_inputs() {
475+
let store = make_store();
476+
let (slug, path) = store
477+
.local_path(
478+
"did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
479+
"hello",
480+
)
481+
.unwrap();
482+
assert_eq!(
483+
slug,
484+
"did_key_z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr"
485+
);
486+
assert!(path.starts_with("/var/lib/gitlawb/repos"));
487+
assert!(path.ends_with("hello.git"));
488+
}
489+
490+
#[tokio::test]
491+
async fn local_path_rejects_traversal_in_repo_name() {
492+
let store = make_store();
493+
for bad in ["../etc/passwd", "..", "../../shadow"] {
494+
assert!(
495+
store.local_path("did:key:z6MkAlice", bad).is_err(),
496+
"repo_name={bad:?} must be rejected"
497+
);
498+
}
499+
}
500+
501+
#[tokio::test]
502+
async fn local_path_rejects_traversal_in_owner_did() {
503+
let store = make_store();
504+
for bad in ["did:key:..", "..", "did/key/foo"] {
505+
assert!(
506+
store.local_path(bad, "hello").is_err(),
507+
"owner_did={bad:?} must be rejected"
508+
);
509+
}
510+
}
511+
}

0 commit comments

Comments
 (0)