Skip to main content

max / makenotwork

Enforce git disk quota on the HTTP push path; validate internal repo_name Risk remediation (fuzz 2026-07-06, Security axis). - M1: the smart-HTTP receive-pack handler enforced authz but never checked the per-account disk quota that the SSH push path did, so a push PAT defeated the 20 GiB backstop over HTTPS. Extract a shared `git::enforce_disk_quota` and call it from BOTH transports (git_ssh + routes::git::raw) so the guard cannot drift onto one path again; move `dir_size_bytes` into the git module and drop the duplicate. - L2: `internal::git_authorize` built a filesystem path from an unvalidated `repo_name`. Apply `git::validate_segment` (now pub(crate)) to owner+repo_name before the join so a crafted name cannot escape the git root even if a caller skips the DB-row check that blocks traversal transitively today. cargo check + clippy clean (offline); git module unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 21:25 UTC
Commit: 70e6575bf8beac650e7874ffcf07356806d1dac1
Parent: f3a6241
4 files changed, +71 insertions, -41 deletions
@@ -214,7 +214,7 @@ impl SyntaxHighlighter {
214 214
215 215 /// Validate that a path segment (owner or repo name) is safe.
216 216 /// Allows ASCII alphanumeric, hyphens, underscores, and dots (not leading).
217 - fn validate_segment(s: &str) -> Result<(), GitError> {
217 + pub(crate) fn validate_segment(s: &str) -> Result<(), GitError> {
218 218 if s.is_empty() || s.len() > 64 {
219 219 return Err(GitError::InvalidName);
220 220 }
@@ -308,6 +308,51 @@ pub fn repo_disk_path(repos_root: &Path, owner: &str, repo: &str) -> Result<Path
308 308 Ok(canonical_repo)
309 309 }
310 310
311 + /// Total size in bytes of all regular files under `dir` (recursive). Returns 0
312 + /// if the directory does not exist. Symlinks are not followed, so a crafted
313 + /// symlink inside a bare repo cannot inflate or escape the measured tree.
314 + pub(crate) fn dir_size_bytes(dir: &Path) -> u64 {
315 + fn walk(dir: &Path, total: &mut u64) {
316 + let Ok(entries) = std::fs::read_dir(dir) else {
317 + return;
318 + };
319 + for entry in entries.flatten() {
320 + let Ok(meta) = entry.metadata() else { continue };
321 + if meta.is_dir() {
322 + walk(&entry.path(), total);
323 + } else if meta.is_file() {
324 + *total += meta.len();
325 + }
326 + }
327 + }
328 + let mut total = 0;
329 + walk(dir, &mut total);
330 + total
331 + }
332 +
333 + /// Enforce the per-account git disk quota against `owner_dir` (the namespace
334 + /// owner's directory under the git root). The recursive walk runs on the
335 + /// blocking pool so a large account can't stall a runtime worker per push.
336 + ///
337 + /// Shared by BOTH push transports — SSH (`git_ssh`) and smart-HTTP
338 + /// (`routes::git::raw`) — so the quota guard cannot drift onto only one path
339 + /// again (fuzz 2026-07-06 M1: the HTTP receive-pack path had no quota check
340 + /// while SSH did). Coarse (summed pre-push, not atomic), but paired with the
341 + /// per-repo `receive.maxInputSize` cap it bounds total growth per account.
342 + pub(crate) async fn enforce_disk_quota(owner_dir: PathBuf) -> anyhow::Result<()> {
343 + let used = tokio::task::spawn_blocking(move || dir_size_bytes(&owner_dir))
344 + .await
345 + .map_err(|e| anyhow::anyhow!("git quota check task failed: {e}"))?;
346 + if used > crate::constants::GIT_USER_DISK_QUOTA_BYTES {
347 + anyhow::bail!(
348 + "push rejected: account git storage quota exceeded ({} of {} bytes used)",
349 + used,
350 + crate::constants::GIT_USER_DISK_QUOTA_BYTES
351 + );
352 + }
353 + Ok(())
354 + }
355 +
311 356 /// Write the standard resource limits into a bare repo's config. Currently
312 357 /// `receive.maxInputSize`, which makes git-receive-pack abort a push whose pack
313 358 /// exceeds the cap. Shared by repo creation and the one-time backfill command so
@@ -124,24 +124,11 @@ async fn exec_git_operation(
124 124 }
125 125
126 126 // Per-account disk quota. The push consumes the *namespace owner's*
127 - // storage, so the quota is checked against `owner_username`. Coarse
128 - // (summed pre-push, not atomic) but paired with the per-repo
129 - // `receive.maxInputSize` cap it bounds total growth per account.
130 - let git_root = git_repos_root();
131 - let owner_dir = git_root.join(owner_username.as_ref());
132 - // The recursive directory walk is synchronous filesystem I/O; run it on
133 - // the blocking pool so a large account can't stall a runtime worker per
134 - // push (ultra-fuzz Run 6 Performance cold spot).
135 - let used = tokio::task::spawn_blocking(move || dir_size_bytes(&owner_dir))
136 - .await
137 - .map_err(|e| anyhow::anyhow!("git quota check task failed: {e}"))?;
138 - if used > crate::constants::GIT_USER_DISK_QUOTA_BYTES {
139 - anyhow::bail!(
140 - "push rejected: account git storage quota exceeded ({} of {} bytes used)",
141 - used,
142 - crate::constants::GIT_USER_DISK_QUOTA_BYTES
143 - );
144 - }
127 + // storage, so the quota is checked against `owner_username`. Enforced
128 + // via the shared `git::enforce_disk_quota` so the SSH and smart-HTTP
129 + // push paths share one guard (fuzz 2026-07-06 M1).
130 + let owner_dir = git_repos_root().join(owner_username.as_ref());
131 + crate::git::enforce_disk_quota(owner_dir).await?;
145 132 }
146 133 GitOperation::UploadPack | GitOperation::Archive => {
147 134 if repo.visibility == db::Visibility::Private && !is_owner {
@@ -178,28 +165,6 @@ fn git_repos_root() -> std::path::PathBuf {
178 165 )
179 166 }
180 167
181 - /// Total size in bytes of all regular files under `dir` (recursive). Returns 0
182 - /// if the directory does not exist. Symlinks are not followed, so a crafted
183 - /// symlink inside a bare repo cannot inflate or escape the measured tree.
184 - fn dir_size_bytes(dir: &std::path::Path) -> u64 {
185 - fn walk(dir: &std::path::Path, total: &mut u64) {
186 - let Ok(entries) = std::fs::read_dir(dir) else {
187 - return;
188 - };
189 - for entry in entries.flatten() {
190 - let Ok(meta) = entry.metadata() else { continue };
191 - if meta.is_dir() {
192 - walk(&entry.path(), total);
193 - } else if meta.is_file() {
194 - *total += meta.len();
195 - }
196 - }
197 - }
198 - let mut total = 0;
199 - walk(dir, &mut total);
200 - total
201 - }
202 -
203 168 fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
204 169 let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
205 170 if parts.len() != 2 {
@@ -194,6 +194,15 @@ pub(super) async fn git_authorize(
194 194 _ => return Err(AppError::BadRequest("unsupported git operation".into())),
195 195 }
196 196
197 + // Validate the path segments before building a filesystem path. `owner` is
198 + // already validated upstream, but `repo_name` reached the `join` unchecked
199 + // (fuzz 2026-07-06 L2); validate both charset-wise (no `/`, `..`, leading
200 + // dot) so a crafted name cannot escape the git root even if a future caller
201 + // skips the DB-row check that currently blocks traversal transitively.
202 + crate::git::validate_segment(&req.owner)
203 + .and_then(|()| crate::git::validate_segment(&req.repo_name))
204 + .map_err(|_| AppError::BadRequest("invalid repository name".into()))?;
205 +
197 206 let repo_path = std::path::Path::new(git_root)
198 207 .join(&req.owner)
199 208 .join(format!("{}.git", req.repo_name));
@@ -316,6 +316,17 @@ pub(super) async fn smart_http_receive_pack(
316 316 let resolved = resolve_repo(&state, &owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
317 317 authorize_push(&state, &resolved, principal.as_ref()).await?;
318 318
319 + // Per-account disk quota, same guard the SSH push path enforces. The owner
320 + // dir is the parent of `{root}/{owner}/{repo}.git` — a canonicalized, root-
321 + // confined path from `resolve_repo`. Without this the HTTP receive-pack path
322 + // let a push PAT defeat the 20 GiB backstop (fuzz 2026-07-06 M1).
323 + let owner_dir = resolved
324 + .repo_path
325 + .parent()
326 + .map(std::path::Path::to_path_buf)
327 + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("git repo path has no owner dir")))?;
328 + git::enforce_disk_quota(owner_dir).await?;
329 +
319 330 let permit = state
320 331 .git_smart_http_semaphore
321 332 .clone()