max / makenotwork
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() |