Skip to content

Commit e29cf9e

Browse files
committed
fix(node): validate git branch refs to close option injection
PR branch refs and a repo's default_branch were stored from request bodies with no ref validation, then interpolated into single git argv elements: git diff {target}...{source} (branch_diff / branch_diff_names), and git worktree add ... {target} / git merge {source} (merge_branch). A value beginning with '-', e.g. --output=/tmp/x, is parsed by git as an option rather than a revision, so it becomes an arbitrary file write. get_pr_diff takes an optional identity, so on a public repo the trigger is unauthenticated; planting the PR needs only read access, and the write happens at the withhold check before the visibility gate. Defense is applied at two layers: - Storage boundaries: create_pr validates source_branch and the resolved target_branch; create_repo validates default_branch (which becomes a PR's target when the PR omits one). These fail fast with 400 and keep junk out of the DB. - The sink: branch_diff, branch_diff_names, and merge_branch reject an option-shaped ref before building the git argv, so the property holds for every caller and every row, including legacy rows and any future writer that skips the boundary check. The shared validator is crate::git::store::validate_git_ref (git check-ref-format rules, leading-dash rejection as the core), re-exported as crate::api::validate_git_ref for the boundary handlers. No -- delimiter is used: the arguments are revisions, and -- there reinterprets them as pathspecs. Both boundary guards and the sink guard are mutation-proven load-bearing. resolve_head is unaffected (it prefixes refs/heads/); fork_repo takes no branch from the request.
1 parent e4c7458 commit e29cf9e

5 files changed

Lines changed: 351 additions & 0 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()
9292
}
9393
}
9494

95+
/// Re-export of the sink-level git ref validator (canonical home:
96+
/// `crate::git::store::validate_git_ref`). Storage boundaries call it here to
97+
/// fail fast with a 400; the sink guards enforce the same property for every
98+
/// caller. Its unit tests live beside the definition in `git/store.rs`.
99+
pub(crate) use crate::git::store::validate_git_ref;
100+
95101
#[cfg(test)]
96102
mod did_tests {
97103
use super::did_matches;

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ pub async fn create_pr(
5050
let target_branch = req
5151
.target_branch
5252
.unwrap_or_else(|| record.default_branch.clone());
53+
54+
// Validate both refs before they are stored, since both are later
55+
// interpolated into git argv (git diff / worktree add / merge). Validate the
56+
// RESOLVED target, not just a caller-supplied one: create_repo also gates
57+
// default_branch, but validating here as well means a PR can never feed the
58+
// git sink an unchecked ref even if a default was poisoned by an older row
59+
// or a future path that skips create_repo's gate.
60+
crate::api::validate_git_ref(&req.source_branch).map_err(AppError::BadRequest)?;
61+
crate::api::validate_git_ref(&target_branch).map_err(AppError::BadRequest)?;
5362
let number = state.db.next_pr_number(&record.id).await?;
5463
let now = Utc::now().to_rfc3339();
5564

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ pub async fn create_repo(
242242
));
243243
}
244244

245+
// default_branch is caller-supplied and becomes a PR's target_branch when the
246+
// PR omits one, which is interpolated into a git revision argument. Validate
247+
// it as a ref so it cannot begin with '-' and inject a git option downstream.
248+
crate::api::validate_git_ref(&req.default_branch).map_err(AppError::BadRequest)?;
249+
245250
// Owner is the authenticated agent's DID
246251
let owner_did = auth.0;
247252

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,8 +687,69 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
687687
Ok(Some((obj_type, content)))
688688
}
689689

690+
/// Validate a git branch ref before it is interpolated into a git argv element.
691+
/// Rules mirror `git check-ref-format`. The security-critical rule is the
692+
/// leading-`-` rejection: without it a name like `--output=/tmp/x` is parsed by
693+
/// git as an option rather than a revision, turning `branch_diff` / `merge_branch`
694+
/// into an arbitrary file write. A `--` delimiter is not the fix: these arguments
695+
/// are revisions, and `--` there reinterprets them as pathspecs.
696+
///
697+
/// This is the sink-level guard. Storage boundaries (`create_pr`, `create_repo`)
698+
/// call it too via the `crate::api` re-export to fail fast with a 400, but the
699+
/// guard here is what makes the property hold for every caller and every row,
700+
/// including legacy rows and any writer that skipped the boundary check.
701+
pub fn validate_git_ref(name: &str) -> std::result::Result<(), String> {
702+
if name.is_empty() {
703+
return Err("branch ref must not be empty".into());
704+
}
705+
// Option-injection core: a leading '-' makes git read the value as a flag.
706+
if name.starts_with('-') {
707+
return Err("branch ref must not begin with '-'".into());
708+
}
709+
if name.len() > 255 {
710+
return Err("branch ref must be at most 255 bytes".into());
711+
}
712+
if name.chars().any(|c| c.is_ascii_control() || c == ' ') {
713+
return Err("branch ref must not contain control characters or spaces".into());
714+
}
715+
if name.contains(['~', '^', ':', '?', '*', '[', '\\']) {
716+
return Err("branch ref must not contain any of ~ ^ : ? * [ \\".into());
717+
}
718+
if name.contains("..") || name.contains("@{") {
719+
return Err("branch ref must not contain '..' or '@{'".into());
720+
}
721+
if name == "@" {
722+
return Err("branch ref must not be '@'".into());
723+
}
724+
if name.starts_with('/') || name.ends_with('/') || name.contains("//") {
725+
return Err("branch ref must not have empty path components".into());
726+
}
727+
if name.ends_with(".lock") {
728+
return Err("branch ref must not end with '.lock'".into());
729+
}
730+
for component in name.split('/') {
731+
if component.starts_with('.') || component.ends_with(".lock") {
732+
return Err(
733+
"no branch ref path component may start with '.' or end with '.lock'".into(),
734+
);
735+
}
736+
}
737+
Ok(())
738+
}
739+
740+
/// Reject both refs at the sink, so an option-shaped ref can never reach a git
741+
/// argv element regardless of how it was stored.
742+
fn guard_refs(target_branch: &str, source_branch: &str) -> Result<()> {
743+
validate_git_ref(target_branch)
744+
.map_err(|e| anyhow::anyhow!("invalid target branch ref: {e}"))?;
745+
validate_git_ref(source_branch)
746+
.map_err(|e| anyhow::anyhow!("invalid source branch ref: {e}"))?;
747+
Ok(())
748+
}
749+
690750
/// Get the diff between two branches: changes on source_branch not in target_branch.
691751
pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -> Result<String> {
752+
guard_refs(target_branch, source_branch)?;
692753
let output = Command::new("git")
693754
.args(["diff", &format!("{target_branch}...{source_branch}")])
694755
.current_dir(repo_path)
@@ -706,6 +767,7 @@ pub fn branch_diff_names(
706767
target_branch: &str,
707768
source_branch: &str,
708769
) -> Result<Vec<String>> {
770+
guard_refs(target_branch, source_branch)?;
709771
let output = Command::new("git")
710772
.args([
711773
"diff",
@@ -740,6 +802,7 @@ pub fn merge_branch(
740802
author_did: &str,
741803
pr_title: &str,
742804
) -> Result<String> {
805+
guard_refs(target_branch, source_branch)?;
743806
let worktree_path = repo_path.join("_merge_worktree");
744807

745808
// Clean up any leftover worktree
@@ -819,6 +882,45 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
819882

820883
#[cfg(test)]
821884
mod tests {
885+
use super::validate_git_ref;
886+
887+
#[test]
888+
fn validate_git_ref_accepts_normal_branch_names() {
889+
for good in [
890+
"main",
891+
"feature/foo",
892+
"release-1.2",
893+
"v1.0.0",
894+
"user/fix-bug",
895+
] {
896+
assert!(
897+
validate_git_ref(good).is_ok(),
898+
"{good:?} should be accepted"
899+
);
900+
}
901+
}
902+
903+
#[test]
904+
fn validate_git_ref_rejects_option_injection_and_malformed_refs() {
905+
let long = "a".repeat(256);
906+
for bad in [
907+
"",
908+
"--output=/tmp/x",
909+
"-rf",
910+
"a b",
911+
"a..b",
912+
"a~b",
913+
"refs/heads/@{x}",
914+
"foo.lock",
915+
"/leading",
916+
"trailing/",
917+
"a//b",
918+
long.as_str(),
919+
] {
920+
assert!(validate_git_ref(bad).is_err(), "{bad:?} should be rejected");
921+
}
922+
}
923+
822924
use super::branch_diff_names;
823925
use std::path::Path;
824926
use std::process::Command;

0 commit comments

Comments
 (0)