fix(node): validate git branch refs to close option injection - #379
fix(node): validate git branch refs to close option injection#379beardthelion wants to merge 1 commit into
Conversation
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.
|
@coderabbitai please review |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
git diff --unified=80 HEAD^ HEAD --
printf '\n--- changed files ---\n'
git diff --name-only HEAD^ HEAD
printf '\n--- Rust symbol outline ---\n'
fd -e rs . | xargs -r ast-grep outline --items all --type function --match 'validate_git_ref|create_pr|create_repo|branch_diff|branch_diff_names|merge_branch|get_pr_diff' 2>/dev/null || trueLength of output: 57930 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- pull handlers and merge path ---'
ast-grep outline crates/gitlawb-node/src/api/pulls.rs --items all --type function --match 'get_pr_diff|merge_pr|create_pr' --view expanded
sed -n '130,380p' crates/gitlawb-node/src/api/pulls.rs
printf '%s\n' '--- git sink call sites and branch-field writers ---'
rg -n -C 5 -P '\b(branch_diff|branch_diff_names|merge_branch)\s*\(|\b(source_branch|target_branch|default_branch)\s*:' crates/gitlawb-node/src --glob '*.rs'
printf '%s\n' '--- Git reference-format behavior for trailing dots ---'
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
git -C "$tmp" init -q
if git -C "$tmp" check-ref-format --branch 'feature.'; then
echo 'unexpected: feature. accepted'
else
echo 'expected: feature. rejected'
fiLength of output: 46104
The option-injection protection is otherwise applied at both request boundaries and all current Git sinks. You are interacting with an AI system. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Merge readiness
- [P1] Rebase onto current
mainand re-review the resolved security diff
crates/gitlawb-node/src/git/store.rs:701
The PR merge base (e4c7458) is behind the live target (73fd747), which has changed the same node API and Git-store security surfaces. Rebase before merge so the option-injection fix is verified against the current authorization and subprocess behavior.
Findings
-
[P2] Validate branch names rather than accepting revision expressions
crates/gitlawb-node/src/git/store.rs:701
validate_git_refis introduced as the shared gate for persisted PRsource_branch/target_branchvalues and repositorydefault_branchvalues, but it implements only part of Git’s branch-name grammar. In particular, it acceptsHEADand components ending in., whilegit check-ref-format --branchrejectsHEAD,feature., andfeature/x..This is not just a validation-message mismatch.
create_praccepts these values, writes the row, and emitspull_request.opened. A trailing-dot name then makes the laterbranch_diff_names,branch_diff, ormerge_branchinvocation fail. More importantly,HEADis a revision expression, not a branch:merge_branchchecks out the target branch and then runsgit merge --no-ff HEAD, which Git treats as the already checked-out target commit and exits successfully without merging a submitted source branch. The handler then resolves the target ref and records the PR as merged, emittingpull_request.mergeddespite no source-branch merge having occurred.Please address the root cause by making this one shared validator enforce the actual branch-name contract—not merely a subset sufficient to block leading options—and retain its use at both request storage boundaries and all legacy-row subprocess sinks. A Git-equivalent branch-format check (or a complete equivalent implementation) should reject symbolic revision names and every forbidden component form, while preserving ordinary branch names and the existing leading-dash protection. Add load-bearing boundary and sink tests for at least
HEAD,feature., andfeature/x.: rejected API requests must create no row or webhook, and direct legacy rows must be rejected before any Git command or merge-side effect.
Closes #378.
PR branch refs and a repo's
default_branchwere stored from request bodies with no ref validation, then interpolated into single git argv elements downstream:git diff {target}...{source}inbranch_diff/branch_diff_names, andgit worktree add ... {target}/git merge {source}inmerge_branch. A value starting with-is read by git as an option, so a stored--output=<path>turns the PR diff endpoint into an arbitrary file write.get_pr_difftakes an optional identity, so the trigger is unauthenticated on a public repo; planting the PR needs only read access, and the write happens at the withhold check before the visibility gate.Two caller-supplied entry points feed those sinks:
create_prstoressource_branchandtarget_branchfrom the body.create_repostoresdefault_branchfrom the body, which becomes a PR'starget_branchwhen the PR omits one.This adds a shared
validate_git_ref(canonical ingit/store.rs, re-exported ascrate::api::validate_git_ref), following git check-ref-format rules with a leading-dash rejection as the core, and calls it at both boundaries.create_prvalidatessource_branchand the resolvedtarget_branch, so a poisoned or legacy default cannot reach the sink even if it bypassedcreate_repo's gate.create_repovalidatesdefault_branch. Both run after the existing auth/name checks and before the row is written. No--delimiter is added at the git call sites: the argument is a revision, and--there would reinterpret it as a pathspec.Not affected:
resolve_headprefixesrefs/heads/before passing the branch to git, so a leading dash cannot lead there;fork_repotakes no branch from the request.The guard runs at two layers. The storage boundaries above fail fast with a 400 and keep option-shaped refs out of the DB. The git sink functions (
branch_diff,branch_diff_names,merge_branch) also reject them before building the argv, so the property holds for every caller and every row, including a row written before this change or by any writer that skips the boundary check.Tests
validate_git_refunit tests: acceptsmain,feature/foo,release-1.2,v1.0.0,user/fix-bug; rejects empty,--output=/tmp/x,-rf, a space,..,~,@{,.lock, leading/trailing///slashes, and an over-255-byte name.create_pras the owner of a public repo with an--output=target returns 400 with no PR row (fails before the fix, which returns 201);create_repowith an--output=default_branchreturns 400 with no repo row.get_pr_diffruns, proving the sink guard, not just the boundary; it is RED without the sink guard.create_pralso rejects an option-shapedsource_branch(thegit mergearm).gitlawb-nodesuite: 829 passed, 0 failed.