Additional Bug fixes
This commit is contained in:
@@ -20,7 +20,7 @@ use tower_http::{
|
||||
catch_panic::CatchPanicLayer,
|
||||
compression::CompressionLayer,
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
services::{ServeDir, ServeFile},
|
||||
services::ServeDir,
|
||||
timeout::TimeoutLayer,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
@@ -71,6 +71,14 @@ struct VerifyPasswordRequest {
|
||||
struct FileQuery {
|
||||
#[serde(default)]
|
||||
download: bool,
|
||||
#[serde(rename = "sc", default)]
|
||||
sc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ScQuery {
|
||||
#[serde(rename = "sc", default)]
|
||||
sc: Option<String>,
|
||||
}
|
||||
|
||||
struct ByteRange {
|
||||
@@ -88,16 +96,21 @@ impl From<CgcxError> for AppError {
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match self.0 {
|
||||
let (status, msg) = match &self.0 {
|
||||
CgcxError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
|
||||
CgcxError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
CgcxError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"),
|
||||
CgcxError::BadRequest(ref m) => (StatusCode::BAD_REQUEST, m.as_str()),
|
||||
CgcxError::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad request"),
|
||||
CgcxError::InvalidContentId(_) => (StatusCode::BAD_REQUEST, "Bad request"),
|
||||
CgcxError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited"),
|
||||
CgcxError::InsufficientStorage => (StatusCode::INSUFFICIENT_STORAGE, "Insufficient storage"),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
|
||||
other => {
|
||||
tracing::error!("Internal server error: {}", other);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error")
|
||||
}
|
||||
};
|
||||
(status, msg.to_string()).into_response()
|
||||
let body = serde_json::json!({ "error": msg });
|
||||
(status, [(header::CONTENT_TYPE, "application/json")], body.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +136,8 @@ async fn main() -> cgcx_core::Result<()> {
|
||||
let config = Arc::new(Config::load()?);
|
||||
config.validate()?;
|
||||
|
||||
tokio::fs::create_dir_all("data").await.ok();
|
||||
|
||||
let db = Arc::new(Database::open("data/db.sqlite")?);
|
||||
db.run_migrations().await?;
|
||||
|
||||
@@ -171,8 +186,7 @@ async fn main() -> cgcx_core::Result<()> {
|
||||
config: Arc::new(password_governor_conf),
|
||||
});
|
||||
|
||||
let static_service = ServeDir::new("frontend/dist")
|
||||
.fallback(ServeFile::new("frontend/dist/index.html"));
|
||||
let static_service = ServeDir::new("frontend/dist/assets");
|
||||
|
||||
let mut origins: Vec<HeaderValue> = vec![
|
||||
config.server.base_url.parse().expect("invalid server.base_url"),
|
||||
@@ -215,12 +229,12 @@ async fn main() -> cgcx_core::Result<()> {
|
||||
.route("/api/content/{cxid}", get(get_metadata))
|
||||
.route("/api/content/{cxid}/file/{file_idx}", get(serve_file))
|
||||
.merge(password_route)
|
||||
.fallback_service(static_service)
|
||||
.nest_service("/assets", static_service)
|
||||
.fallback(fallback)
|
||||
.layer(tower_governor::GovernorLayer {
|
||||
config: Arc::new(governor_conf),
|
||||
})
|
||||
.layer(compression)
|
||||
.layer(cors)
|
||||
.layer(axum::middleware::from_fn(security_headers))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
@@ -228,6 +242,7 @@ async fn main() -> cgcx_core::Result<()> {
|
||||
Duration::from_secs(30),
|
||||
))
|
||||
.layer(CatchPanicLayer::new())
|
||||
.layer(cors)
|
||||
.with_state(state.clone());
|
||||
|
||||
// Spawn background sweeper task
|
||||
@@ -259,6 +274,18 @@ async fn main() -> cgcx_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fallback(uri: axum::http::Uri) -> Response {
|
||||
let path = uri.path();
|
||||
tracing::info!("fallback: path={}", path);
|
||||
if path.starts_with("/api/") {
|
||||
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Not found"}))).into_response();
|
||||
}
|
||||
match tokio::fs::read_to_string("frontend/dist/index.html").await {
|
||||
Ok(html) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/html")], html).into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Frontend not built").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Response {
|
||||
let mut response = next.run(req).await;
|
||||
let headers = response.headers_mut();
|
||||
@@ -281,21 +308,56 @@ async fn security_headers(req: axum::http::Request<Body>, next: Next) -> Respons
|
||||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
tracing::info!("health");
|
||||
axum::Json(HealthResponse {
|
||||
status: "ok".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn password_from_request(
|
||||
headers: &HeaderMap,
|
||||
query_sc: Option<&str>,
|
||||
cxid: &str,
|
||||
password_hash: Option<&str>,
|
||||
cookie_secret: &[u8],
|
||||
) -> bool {
|
||||
if let Some(sc) = query_sc {
|
||||
if let Some(hash) = password_hash {
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
if let Ok(parsed_hash) = PasswordHash::new(hash) {
|
||||
if Argon2::default().verify_password(sc.as_bytes(), &parsed_hash).is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.any(|v| {
|
||||
v.to_str().ok().map(|s| {
|
||||
s.split(';').any(|part| {
|
||||
let part = part.trim();
|
||||
part.starts_with("cgcx_pw=") && verify_cookie(cxid, &part[8..], cookie_secret)
|
||||
})
|
||||
}).unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(cxid): Path<String>,
|
||||
Query(query): Query<ScQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
tracing::info!("get_metadata: cxid={}", cxid);
|
||||
let content_id = ContentId::try_from(cxid.as_str())?;
|
||||
let repo = ContentRepo::new(state.db.conn());
|
||||
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
||||
|
||||
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
|
||||
tracing::warn!("get_metadata returning NotFound for cxid={}", cxid);
|
||||
return Err(CgcxError::NotFound.into());
|
||||
}
|
||||
|
||||
@@ -304,23 +366,13 @@ async fn get_metadata(
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::GONE)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||
}
|
||||
}
|
||||
|
||||
if content.password_hash.is_some() {
|
||||
let cookie_valid = headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.any(|v| {
|
||||
v.to_str().ok().map(|s| {
|
||||
s.split(';').any(|part| {
|
||||
let part = part.trim();
|
||||
part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret)
|
||||
})
|
||||
}).unwrap_or(false)
|
||||
});
|
||||
if !cookie_valid {
|
||||
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
|
||||
tracing::warn!("get_metadata returning Unauthorized for cxid={}", cxid);
|
||||
return Err(CgcxError::Unauthorized.into());
|
||||
}
|
||||
}
|
||||
@@ -342,12 +394,12 @@ async fn get_metadata(
|
||||
current_views: content.view_count,
|
||||
allow_download: content.allow_download,
|
||||
created_at: content.created_at.to_rfc3339(),
|
||||
}).map_err(|e| CgcxError::BadRequest(format!("json serialization: {}", e)))?;
|
||||
}).map_err(|_| CgcxError::BadRequest("json serialization".into()))?;
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap())
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
|
||||
}
|
||||
|
||||
async fn verify_password(
|
||||
@@ -355,6 +407,7 @@ async fn verify_password(
|
||||
Path(cxid): Path<String>,
|
||||
Json(req): Json<VerifyPasswordRequest>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
tracing::info!("verify_password: cxid={}", cxid);
|
||||
let content_id = ContentId::try_from(cxid.as_str())?;
|
||||
let repo = ContentRepo::new(state.db.conn());
|
||||
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
||||
@@ -363,7 +416,7 @@ async fn verify_password(
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||
};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
@@ -373,12 +426,13 @@ async fn verify_password(
|
||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
if !valid {
|
||||
tracing::warn!("verify_password returning Unauthorized for cxid={}", cxid);
|
||||
return Err(CgcxError::Unauthorized.into());
|
||||
}
|
||||
|
||||
let cookie_value = make_cookie_value(&cxid, &state.cookie_secret);
|
||||
let cookie = format!(
|
||||
"__Host-pw={}; Max-Age=3600; SameSite=Strict; Secure; HttpOnly; Path=/",
|
||||
"cgcx_pw={}; Max-Age=3600; SameSite=Strict; HttpOnly; Path=/",
|
||||
cookie_value
|
||||
);
|
||||
|
||||
@@ -386,7 +440,7 @@ async fn verify_password(
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header(header::SET_COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
@@ -395,11 +449,13 @@ async fn serve_file(
|
||||
Query(query): Query<FileQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
tracing::info!("serve_file: cxid={} file_idx={}", cxid, file_idx);
|
||||
let content_id = ContentId::try_from(cxid.as_str())?;
|
||||
let repo = ContentRepo::new(state.db.conn());
|
||||
let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?;
|
||||
|
||||
if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted {
|
||||
tracing::warn!("serve_file returning NotFound for cxid={}", cxid);
|
||||
return Err(CgcxError::NotFound.into());
|
||||
}
|
||||
|
||||
@@ -408,28 +464,19 @@ async fn serve_file(
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::GONE)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||
}
|
||||
}
|
||||
|
||||
if content.password_hash.is_some() {
|
||||
let cookie_valid = headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.any(|v| {
|
||||
v.to_str().ok().map(|s| {
|
||||
s.split(';').any(|part| {
|
||||
let part = part.trim();
|
||||
part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret)
|
||||
})
|
||||
}).unwrap_or(false)
|
||||
});
|
||||
if !cookie_valid {
|
||||
if !password_from_request(&headers, query.sc.as_deref(), &cxid, content.password_hash.as_deref(), &state.cookie_secret) {
|
||||
tracing::warn!("serve_file returning Unauthorized for cxid={}", cxid);
|
||||
return Err(CgcxError::Unauthorized.into());
|
||||
}
|
||||
}
|
||||
|
||||
if query.download && !content.allow_download {
|
||||
tracing::warn!("serve_file returning Forbidden (download not allowed) for cxid={}", cxid);
|
||||
return Err(CgcxError::Forbidden.into());
|
||||
}
|
||||
|
||||
@@ -437,6 +484,27 @@ async fn serve_file(
|
||||
let files = file_repo.list_by_content(&content_id).await?;
|
||||
let file = files.iter().find(|f| f.file_index == file_idx).ok_or(CgcxError::NotFound)?;
|
||||
|
||||
// Handle zero-size files early to avoid underflow in range parsing
|
||||
if file.size_bytes == 0 {
|
||||
let etag = format!("\"{}\"", hex::encode(&file.encrypted_hash));
|
||||
let content_type = file.mime_type.clone();
|
||||
let sanitized_name = sanitize_content_disposition(&file.original_name);
|
||||
let disposition = if query.download && content.allow_download {
|
||||
format!("attachment; filename=\"{}\"", sanitized_name)
|
||||
} else {
|
||||
format!("inline; filename=\"{}\"", sanitized_name)
|
||||
};
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_DISPOSITION, disposition)
|
||||
.header(header::ETAG, etag)
|
||||
.header(header::CONTENT_LENGTH, "0")
|
||||
.header(header::CACHE_CONTROL, "private, no-store, max-age=0")
|
||||
.body(Body::empty())
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||
}
|
||||
|
||||
// Path traversal validation
|
||||
let canonical_path = tokio::fs::canonicalize(&file.stored_path).await
|
||||
.map_err(|e| {
|
||||
@@ -445,6 +513,7 @@ async fn serve_file(
|
||||
})?;
|
||||
if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) {
|
||||
tracing::error!("Path traversal blocked: {:?}", canonical_path);
|
||||
tracing::warn!("serve_file returning Forbidden (path traversal) for cxid={}", cxid);
|
||||
return Err(CgcxError::Forbidden.into());
|
||||
}
|
||||
|
||||
@@ -457,7 +526,7 @@ async fn serve_file(
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(header::ETAG, etag.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +540,7 @@ async fn serve_file(
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{}", file.size_bytes))
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
.map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -559,7 +628,7 @@ async fn serve_file(
|
||||
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
let body = Body::from_stream(body_stream);
|
||||
|
||||
Ok(response.body(body).unwrap())
|
||||
Ok(response.body(body).map_err(|e| CgcxError::Storage(format!("response build failed: {}", e)))?)
|
||||
}
|
||||
|
||||
async fn stream_decrypted_file(
|
||||
@@ -587,6 +656,10 @@ async fn stream_decrypted_file(
|
||||
break; // EOF at message boundary
|
||||
}
|
||||
let msg_len = u32::from_le_bytes(len_buf) as usize;
|
||||
if msg_len > 50_000_000 {
|
||||
let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "message too large"))).await;
|
||||
return Err(CgcxError::Crypto("message length exceeds sanity bound".into()));
|
||||
}
|
||||
let mut msg_buf = vec![0u8; msg_len];
|
||||
file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user