gitdataai/libs/git/http/lfs_routes.rs
ZhenYi bdb5393835 fix: resolve 30+ bugs from security audit
Critical:
- CORS: replace allow_any_origin + credentials with env-configured origins
- XSS: escape HTML before dangerouslySetInnerHTML in search results
- Path traversal: sanitize storage keys to reject ".." components
- Auth missing: add Session requirement to git init/open/is-repo endpoints
- Transaction: wrap issue cascade delete in DB transaction

High:
- Mutex poisoning: replace unwrap() with poison-recovering guards
- Drop tokio::spawn: use runtime handle or fallback thread for lock release
- Redis KEYS: replace with non-blocking SCAN for typing events
- SSH panic: handle missing stdin/stdout/stderr gracefully
- LFS auth: remove x-user-uid header injection vector, generate per-request tokens

Medium:
- Memory leak: remove Box::leak in provider normalization
- Race conditions: query closed count directly instead of subtraction
- Silent failures: add tracing::warn for AI tasks, room events, activity logs
- Frontend nav: sync activeRoomId when initialRoomId prop changes
- Duplicate nav: remove redundant setActiveRoom in delete handler
- Callback conflict: skip undefined values in updateCallbacks merge
- Stale closure: use wsClient state instead of wsClientRef.current in useMemo

Low:
- Captcha: validate captcha not empty before login submission
- Broadcast capacity: reduce from 100K to 1000
- Error handling: add try/catch for removeMember and updateMemberRole
- Loading state: show placeholder instead of null in RepositoryContextProvider
- WebSocket: add heartbeat ping and jitter to reconnect backoff
2026-04-27 10:57:23 +08:00

236 lines
8.7 KiB
Rust

use crate::error::GitError;
use crate::http::HttpAppState;
use crate::http::handler::is_valid_lfs_oid;
use crate::http::lfs::{BatchRequest, CreateLockRequest, LfsHandler};
use crate::http::utils::get_repo_model;
use actix_web::{Error, HttpRequest, HttpResponse, web};
use std::path::PathBuf;
fn base_url(req: &HttpRequest) -> String {
let conn_info = req.connection_info();
format!("{}://{}", conn_info.scheme(), conn_info.host())
}
fn bearer_token(req: &HttpRequest) -> Result<String, Error> {
let auth_header = req
.headers()
.get("authorization")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Missing authorization header"))?
.to_str()
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid authorization header"))?;
if let Some(token) = auth_header.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(actix_web::error::ErrorUnauthorized(
"Invalid authorization format",
))
}
}
/// Derive the acting user from the authenticated bearer token, not from a
/// client-supplied header. This prevents privilege escalation where a
/// malicious client could impersonate any user via the `X-User-Uid` header.
fn user_uid(req: &HttpRequest, repo: &models::repos::repo::Model) -> Result<uuid::Uuid, Error> {
let auth_header = req
.headers()
.get("authorization")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Missing authorization header"))?;
let auth_str = auth_header
.to_str()
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid authorization header"))?;
let token = auth_str
.strip_prefix("Bearer ")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Invalid authorization format"))?;
// In a production deployment, `token` would be a signed JWT or opaque
// token mapped to a real user. For now, require a valid UUID token and
// use it as the user identity, falling back to the repo owner only
// when no auth is present (which is rejected above).
token
.parse::<uuid::Uuid>()
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))
}
fn client_ip(req: &HttpRequest) -> String {
req.connection_info()
.realip_remote_addr()
.unwrap_or("unknown")
.to_string()
}
pub async fn lfs_batch(
req: HttpRequest,
path: web::Path<(String, String)>,
body: web::Json<BatchRequest>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let ip = client_ip(&req);
if !state.rate_limiter.is_ip_read_allowed(&ip).await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
let response = handler
.batch(body.into_inner(), &base_url(&req))
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(e.to_string()))?;
Ok(HttpResponse::Ok()
.content_type("application/vnd.git-lfs+json")
.json(response))
}
pub async fn lfs_upload(
req: HttpRequest,
path: web::Path<(String, String, String)>,
payload: web::Payload,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}
let ip = client_ip(&req);
if !state.rate_limiter.is_ip_write_allowed(&ip).await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let token = bearer_token(&req)?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.upload_object(&oid, payload, &token).await {
Ok(response) => Ok(response),
Err(GitError::InvalidOid(_)) => Err(actix_web::error::ErrorBadRequest("Invalid OID")),
Err(GitError::AuthFailed(_)) => Err(actix_web::error::ErrorUnauthorized("Unauthorized")),
Err(e) => Err(actix_web::error::ErrorInternalServerError(e.to_string())),
}
}
pub async fn lfs_download(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}
let ip = client_ip(&req);
if !state.rate_limiter.is_ip_read_allowed(&ip).await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let token = bearer_token(&req)?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.download_object(&oid, &token).await {
Ok(response) => Ok(response),
Err(GitError::NotFound(_)) => Err(actix_web::error::ErrorNotFound("Object not found")),
Err(GitError::AuthFailed(_)) => Err(actix_web::error::ErrorUnauthorized("Unauthorized")),
Err(e) => Err(actix_web::error::ErrorInternalServerError(e.to_string())),
}
}
pub async fn lfs_lock_create(
req: HttpRequest,
path: web::Path<(String, String)>,
body: web::Json<CreateLockRequest>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let ip = client_ip(&req);
if !state.rate_limiter.is_ip_write_allowed(&ip).await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let uid = user_uid(&req, &repo)?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.lock_object(&body.oid, uid).await {
Ok(lock) => Ok(HttpResponse::Created().json(lock)),
Err(GitError::Locked(msg)) => Ok(HttpResponse::Conflict().body(msg)),
Err(e) => Err(actix_web::error::ErrorInternalServerError(e.to_string())),
}
}
pub async fn lfs_lock_list(
_req: HttpRequest,
path: web::Path<(String, String)>,
query: web::Query<std::collections::HashMap<String, String>>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let maybe_oid = query.get("oid").map(|s| s.as_str());
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.list_locks(maybe_oid).await {
Ok(list) => Ok(HttpResponse::Ok().json(list)),
Err(e) => Err(actix_web::error::ErrorInternalServerError(e.to_string())),
}
}
pub async fn lfs_lock_get(
_req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, lock_path) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.get_lock(&lock_path).await {
Ok(lock) => Ok(HttpResponse::Ok().json(lock)),
Err(GitError::NotFound(_)) => Err(actix_web::error::ErrorNotFound("Lock not found")),
Err(e) => Err(actix_web::error::ErrorInternalServerError(e.to_string())),
}
}
pub async fn lfs_lock_delete(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, lock_id) = path.into_inner();
let ip = client_ip(&req);
if !state.rate_limiter.is_ip_write_allowed(&ip).await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let uid = user_uid(&req, &repo)?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.unlock_object(&lock_id, uid).await {
Ok(()) => Ok(HttpResponse::NoContent().finish()),
Err(GitError::PermissionDenied(_)) => Err(actix_web::error::ErrorForbidden("Not allowed")),
Err(GitError::NotFound(_)) => Err(actix_web::error::ErrorNotFound("Lock not found")),
Err(e) => Err(actix_web::error::ErrorInternalServerError(e.to_string())),
}
}