diff --git a/src/auth/github.rs b/src/auth/github.rs index 13519bb..9093120 100644 --- a/src/auth/github.rs +++ b/src/auth/github.rs @@ -47,9 +47,11 @@ pub struct GithubErrorResponse { error_uri: String, } +#[derive(Clone)] pub struct GithubClient { client_id: String, client_secret: String, + http_client: Client, } #[derive(Serialize)] @@ -77,13 +79,18 @@ pub struct GitHubFetchedUser { } impl GithubClient { - pub fn new(client_id: String, client_secret: String) -> GithubClient { + pub fn new(client_id: String, client_secret: String, http_client: Client) -> GithubClient { GithubClient { client_id, client_secret, + http_client, } } + pub fn client_id(&self) -> &str { + &self.client_id + } + pub async fn start_polling_auth( &self, ip: IpNetwork, @@ -98,7 +105,8 @@ impl GithubClient { } } - let res = Client::new() + let res = self + .http_client .post("https://github.com/login/device/code") .header("Accept", HeaderValue::from_static("application/json")) .basic_auth(&self.client_id, Some(&self.client_secret)) @@ -172,7 +180,8 @@ impl GithubClient { } }; - let resp = Client::new() + let resp = self + .http_client .post("https://github.com/login/oauth/access_token") .header("Accept", HeaderValue::from_str("application/json").unwrap()) .header( @@ -203,7 +212,8 @@ impl GithubClient { .to_string()) } pub async fn get_user(&self, token: &str) -> Result { - let resp = Client::new() + let resp = self + .http_client .get("https://api.github.com/user") .header("Accept", HeaderValue::from_str("application/json").unwrap()) .header("User-Agent", "geode_index") @@ -234,8 +244,8 @@ impl GithubClient { &self, token: &str, ) -> Result { - let client = Client::new(); - let resp = client + let resp = self + .http_client .get("https://api.github.com/installation/repositories") .header("Accept", HeaderValue::from_str("application/json").unwrap()) .header("User-Agent", "geode_index") diff --git a/src/config.rs b/src/config.rs index 2fe8938..9da9295 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ use moka::future::Cache; use tokio::sync::mpsc::Sender; use crate::{ + auth::github::GithubClient, dns::ValidateDnsResolver, endpoints::mods::IndexQueryParams, s3_worker::S3WorkerTask, @@ -22,7 +23,7 @@ pub struct AppData { db: sqlx::postgres::PgPool, app_url: String, front_url: String, - github: GitHubClientData, + github_client: GithubClient, webhook_url: String, index_admin_webhook_url: String, static_storage: PublicDisk, @@ -41,12 +42,6 @@ pub struct AppData { s3_sender: OnceLock>, } -#[derive(Clone)] -pub struct GitHubClientData { - client_id: String, - client_secret: String, -} - pub async fn build_config() -> anyhow::Result { let env_url = dotenvy::var("DATABASE_URL")?; @@ -94,14 +89,17 @@ pub async fn build_config() -> anyhow::Result { .timeout(Duration::from_secs(30)) .build()?; + let http_client = reqwest::Client::builder() + .pool_max_idle_per_host(4) + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .build()?; + Ok(AppData { db: pool, app_url: app_url.clone(), front_url, - github: GitHubClientData { - client_id: github_client, - client_secret: github_secret, - }, + github_client: GithubClient::new(github_client, github_secret, http_client.clone()), webhook_url, index_admin_webhook_url, static_storage: PublicDisk::new( @@ -119,26 +117,12 @@ pub async fn build_config() -> anyhow::Result { port, debug, mods_cache, - http_client: reqwest::Client::builder() - .pool_max_idle_per_host(4) - .connect_timeout(Duration::from_secs(10)) - .read_timeout(Duration::from_secs(30)) - .build()?, + http_client, check_dns_http_client, s3_sender: OnceLock::new(), }) } -impl GitHubClientData { - pub fn client_id(&self) -> &str { - &self.client_id - } - - pub fn client_secret(&self) -> &str { - &self.client_secret - } -} - impl AppData { pub fn db(&self) -> &sqlx::postgres::PgPool { &self.db @@ -152,8 +136,8 @@ impl AppData { &self.front_url } - pub fn github(&self) -> &GitHubClientData { - &self.github + pub fn github_client(&self) -> &GithubClient { + &self.github_client } pub fn webhook_url(&self) -> &str { diff --git a/src/endpoints/auth/github.rs b/src/endpoints/auth/github.rs index d79b988..cf27c3c 100644 --- a/src/endpoints/auth/github.rs +++ b/src/endpoints/auth/github.rs @@ -11,7 +11,7 @@ use crate::database::repository::{ }; use crate::endpoints::ApiError; use crate::endpoints::auth::TokensResponse; -use crate::{auth::github, types::api::ApiResponse}; +use crate::types::api::ApiResponse; #[derive(Deserialize, ToSchema)] struct PollParams { @@ -46,10 +46,7 @@ pub async fn start_github_login( info: ConnectionInfo, ) -> Result { let mut pool = data.db().acquire().await?; - let client = github::GithubClient::new( - data.github().client_id().to_string(), - data.github().client_secret().to_string(), - ); + let client = data.github_client(); let Some(ip) = info.realip_remote_addr() else { return Err(ApiError::InternalError( @@ -83,12 +80,13 @@ pub async fn start_github_web_login(data: web::Data) -> Result, data: web::Data, ) -> Result { - let client = github::GithubClient::new( - data.github().client_id().to_string(), - data.github().client_secret().to_string(), - ); + let client = data.github_client(); let user = match client.get_user(&json.token).await { Err(_) => client.get_installation(&json.token).await.map_err(|e| { tracing::error!(error = ?e, "invalid access token"); - ApiError::BadRequest(format!("Invalid access token: {}", json.token)) + ApiError::BadRequest("Invalid access token".to_owned()) })?, Ok(u) => u, diff --git a/src/endpoints/loader.rs b/src/endpoints/loader.rs index 3434566..fa98f22 100644 --- a/src/endpoints/loader.rs +++ b/src/endpoints/loader.rs @@ -175,7 +175,7 @@ pub async fn get_many( platform: query.platform, prerelease: query.prerelease.unwrap_or_default(), }, - query.per_page.unwrap_or(10), + query.per_page.unwrap_or(10).min(50), query.page.unwrap_or(1), &mut pool, ) diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index f020bc0..7bcb35b 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -3,6 +3,7 @@ use crate::{ types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, }; use actix_web::{HttpResponse, http::StatusCode}; +use validator::{ValidationError, ValidationErrors}; pub mod auth; pub mod deprecations; @@ -77,3 +78,88 @@ impl actix_web::ResponseError for ApiError { HttpResponse::build(self.status_code()).json(self.as_response()) } } + +// validator errors + +impl From for ApiError { + fn from(value: ValidationError) -> Self { + ApiError::BadRequest(format_validation_error(&value)) + } +} + +impl From for ApiError { + fn from(value: ValidationErrors) -> Self { + ApiError::BadRequest(format_validation_errors(&value)) + } +} + +pub fn format_validation_error(e: &validator::ValidationError) -> String { + if let Some(msg) = &e.message { + return msg.to_string(); + } + + match e.code.as_ref() { + "length" => { + let min = e + .params + .get("min") + .and_then(|v| v.as_u64()) + .map(|v| v.to_string()); + + let max = e + .params + .get("max") + .and_then(|v| v.as_u64()) + .map(|v| v.to_string()); + + match (min, max) { + (Some(min), Some(max)) => { + format!("length must be between {min} and {max} characters") + } + (Some(min), None) => format!("length must be at least {min} characters"), + (None, Some(max)) => format!("length must be at most {max} characters"), + (None, None) => "invalid length".to_string(), + } + } + + e => e.to_owned(), + } +} + +pub fn format_validation_errors(e: &validator::ValidationErrors) -> String { + use validator::ValidationErrorsKind; + + let mut str_errors = Vec::new(); + + for (field, err) in e.errors() { + match err { + ValidationErrorsKind::Struct(s) => { + str_errors.push(format!( + "field '{field}' is invalid ({})", + format_validation_errors(s) + )); + } + + ValidationErrorsKind::Field(errors) => { + let joined = errors + .iter() + .map(format_validation_error) + .collect::>() + .join(", "); + + str_errors.push(format!("field '{field}' is invalid: {}", joined)); + } + + ValidationErrorsKind::List(map) => { + for (index, errors) in map { + str_errors.push(format!( + "field '{field}' at index {index} is invalid ({})", + format_validation_errors(errors) + )); + } + } + } + } + + str_errors.join(", ") +} diff --git a/src/endpoints/mod_version_submissions.rs b/src/endpoints/mod_version_submissions.rs index 9b1954e..28f26a5 100644 --- a/src/endpoints/mod_version_submissions.rs +++ b/src/endpoints/mod_version_submissions.rs @@ -17,6 +17,7 @@ use crate::webhook::discord::DiscordWebhook; use actix_multipart::Multipart; use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; use futures::StreamExt; +use image::{DynamicImage, ImageReader, Limits}; use serde::Deserialize; use sqlx::{Acquire, PgConnection}; use std::collections::HashMap; @@ -839,7 +840,7 @@ pub async fn upload_attachments( images .into_iter() .map(|raw| { - let img = image::load_from_memory(&raw) + let img = safe_image_decode(&raw) .map_err(|e| ApiError::BadRequest(format!("Invalid image: {e}")))?; let mut webp_bytes: Vec = Vec::new(); img.write_to( @@ -999,3 +1000,14 @@ pub async fn delete_attachment( Ok(HttpResponse::NoContent()) } + +fn safe_image_decode(raw: &[u8]) -> Result { + let mut limits = Limits::default(); + limits.max_image_width = Some(2048); + limits.max_image_height = Some(2048); + limits.max_alloc = Some(64 * 1024 * 1024); + + let mut reader = ImageReader::new(std::io::Cursor::new(raw)); + reader.limits(limits); + reader.with_guessed_format()?.decode() +} diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index 3d48b99..ea76d5d 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -4,6 +4,7 @@ use actix_web::{HttpResponse, Responder, dev::ConnectionInfo, get, post, put, we use serde::Deserialize; use sqlx::{Acquire, types::ipnetwork::IpNetwork}; use utoipa::{IntoParams, ToSchema}; +use validator::Validate; use crate::config::AppData; use crate::database::repository::{ @@ -44,8 +45,9 @@ pub struct GetOnePath { version: String, } -#[derive(Deserialize, ToSchema)] +#[derive(Deserialize, ToSchema, Validate)] pub struct CreateQueryParams { + #[validate(length(max = 1024))] download_link: String, } @@ -121,7 +123,7 @@ pub async fn get_version_index( mod_version::IndexQuery { mod_id: path.id.clone(), page: query.page.unwrap_or(1), - per_page: query.per_page.unwrap_or(10), + per_page: query.per_page.unwrap_or(10).min(50), compare, gd: query.gd, platforms, @@ -310,6 +312,8 @@ pub async fn create_version( payload: web::Json, auth: Auth, ) -> Result { + payload.validate()?; + let dev = auth.developer()?; let mut pool = data.db().acquire().await?; diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index b6c622b..e01af9e 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -30,6 +30,9 @@ use serde::Deserialize; use serde::Serialize; use sqlx::Acquire; use utoipa::{IntoParams, ToSchema}; +use validator::Validate; + +const MAX_UPDATE_BATCH_SIZE: usize = 200; #[derive(Deserialize, Default, Hash, Eq, PartialEq, ToSchema)] #[serde(rename_all = "snake_case")] @@ -63,8 +66,9 @@ pub struct IndexQueryParams { pub status: Option, } -#[derive(Deserialize, ToSchema)] +#[derive(Deserialize, ToSchema, Validate)] pub struct CreateQueryParams { + #[validate(length(max = 1024))] download_link: String, } @@ -215,8 +219,9 @@ pub async fn create( payload: web::Json, auth: Auth, ) -> Result { + payload.validate()?; + let dev = auth.developer()?; - let mut pool = data.db().acquire().await?; let bytes = mod_zip::download_mod( data.check_dns_http_client(), &payload.download_link, @@ -226,6 +231,7 @@ pub async fn create( let json = ModJson::from_zip(&bytes, &payload.download_link, false)?; json.validate()?; + let mut pool = data.db().acquire().await?; let existing: Option = mods::get_one(&json.id, false, &mut pool).await?; if json.id.starts_with("geode.") && !dev.admin { @@ -364,6 +370,7 @@ pub async fn get_mod_updates( let ids = query .ids .split(';') + .take(MAX_UPDATE_BATCH_SIZE) .map(String::from) .collect::>(); diff --git a/src/endpoints/stats.rs b/src/endpoints/stats.rs index 158636f..8ca6dea 100644 --- a/src/endpoints/stats.rs +++ b/src/endpoints/stats.rs @@ -19,6 +19,6 @@ pub async fn get_stats(data: web::Data) -> Result anyhow::Result<()> { .allow_any_origin() .allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]) .allow_any_header() - .supports_credentials() .max_age(3600), ) .wrap(tracing_actix_web::TracingLogger::default()); diff --git a/src/mod_zip.rs b/src/mod_zip.rs index a11e32b..b9b1fec 100644 --- a/src/mod_zip.rs +++ b/src/mod_zip.rs @@ -48,7 +48,8 @@ pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ModZi } let mut logo: Vec = Vec::with_capacity(file.size() as usize); - file.read_to_end(&mut logo) + file.take(file.size()) + .read_to_end(&mut logo) .inspect_err(|e| tracing::error!("logo.png read fail: {}", e))?; let mut reader = BufReader::new(Cursor::new(logo)); diff --git a/src/storage/local.rs b/src/storage/local.rs index ffc2eaa..f328f92 100644 --- a/src/storage/local.rs +++ b/src/storage/local.rs @@ -11,6 +11,17 @@ impl LocalBackend { base_path: base_path.into(), } } + + fn safe_join(&self, suffix: &str) -> Result { + let path = self.base_path.join(suffix); + if path.starts_with(&self.base_path) { + Ok(path) + } else { + Err(StorageError::Other( + "Attempted accessing a path outside storage directory".to_owned(), + )) + } + } } impl StorageBackend for LocalBackend { @@ -20,7 +31,7 @@ impl StorageBackend for LocalBackend { data: &'a [u8], ) -> BoxFuture<'a, StorageResult<()>> { Box::pin(async move { - let path = self.base_path.join(relative_path); + let path = self.safe_join(relative_path)?; if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } @@ -31,7 +42,7 @@ impl StorageBackend for LocalBackend { fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, StorageResult>> { Box::pin(async move { - let path = self.base_path.join(path); + let path = self.safe_join(path)?; match tokio::fs::read(path).await { Ok(data) => Ok(data), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), @@ -43,14 +54,14 @@ impl StorageBackend for LocalBackend { fn exists<'a>(&'a self, path: &'a str) -> BoxFuture<'a, StorageResult> { Box::pin(async move { - let path = self.base_path.join(path); + let path = self.safe_join(path)?; Ok(tokio::fs::metadata(path).await.is_ok()) }) } fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, StorageResult<()>> { Box::pin(async move { - let path = self.base_path.join(path); + let path = self.safe_join(path)?; match tokio::fs::remove_file(path).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), diff --git a/src/types/mod_json.rs b/src/types/mod_json.rs index 227d1c0..234eeec 100644 --- a/src/types/mod_json.rs +++ b/src/types/mod_json.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use validator::{Validate, ValidationError}; use zip::read::ZipFile; +use crate::endpoints::format_validation_errors; use crate::mod_zip::{self, ModZipError}; use super::models::{ @@ -244,7 +245,7 @@ impl ModJson { } json.about = Some( - parse_zip_entry_to_str(&mut file) + parse_zip_entry_to_str(&mut file, MAX_MARKDOWN_FILE_SIZE) .inspect_err(|e| { tracing::error!("Failed to parse about.md for mod: {e}") }) @@ -261,7 +262,7 @@ impl ModJson { } json.changelog = Some( - parse_zip_entry_to_str(&mut file) + parse_zip_entry_to_str(&mut file, MAX_MARKDOWN_FILE_SIZE) .inspect_err(|e| tracing::error!("Failed to parse changelog.md: {e}")) .map_err(|e| { ModZipError::InvalidModJson(format!( @@ -502,7 +503,7 @@ impl ModJson { pub fn validate(&self) -> Result<(), ModZipError> { if let Err(e) = ::validate(self) { tracing::warn!("mod.json validation error: {e}"); - let useful_error = extract_validation_error(&e); + let useful_error = format_validation_errors(&e); return Err(ModZipError::InvalidModJson(format!( "validation error: {useful_error}" ))); @@ -616,9 +617,9 @@ impl ModJson { } } -fn parse_zip_entry_to_str(file: &mut ZipFile) -> Result { +fn parse_zip_entry_to_str(file: &mut ZipFile, limit: u64) -> Result { let mut string: String = String::from(""); - match file.read_to_string(&mut string) { + match file.take(limit).read_to_string(&mut string) { Ok(_) => Ok(string), Err(e) => { tracing::error!("{}", e); @@ -728,74 +729,3 @@ fn validate_vec_string(vec: &Vec) -> Result<(), ValidationError> { } Ok(()) } - -fn map_field_error(e: &validator::ValidationError) -> String { - if let Some(msg) = &e.message { - return msg.to_string(); - } - - match e.code.as_ref() { - "length" => { - let min = e - .params - .get("min") - .and_then(|v| v.as_u64()) - .map(|v| v.to_string()); - - let max = e - .params - .get("max") - .and_then(|v| v.as_u64()) - .map(|v| v.to_string()); - - match (min, max) { - (Some(min), Some(max)) => { - format!("length must be between {min} and {max} characters") - } - (Some(min), None) => format!("length must be at least {min} characters"), - (None, Some(max)) => format!("length must be at most {max} characters"), - (None, None) => "invalid length".to_string(), - } - } - - e => e.to_owned(), - } -} - -fn extract_validation_error(e: &validator::ValidationErrors) -> String { - use validator::ValidationErrorsKind; - - let mut str_errors = Vec::new(); - - for (field, err) in e.errors() { - match err { - ValidationErrorsKind::Struct(s) => { - str_errors.push(format!( - "field '{field}' is invalid ({})", - extract_validation_error(s) - )); - } - - ValidationErrorsKind::Field(errors) => { - let joined = errors - .iter() - .map(map_field_error) - .collect::>() - .join(", "); - - str_errors.push(format!("field '{field}' is invalid: {}", joined)); - } - - ValidationErrorsKind::List(map) => { - for (index, errors) in map { - str_errors.push(format!( - "field '{field}' at index {index} is invalid ({})", - extract_validation_error(errors) - )); - } - } - } - } - - str_errors.join(", ") -} diff --git a/src/types/models/stats.rs b/src/types/models/stats.rs index 5d8bbb5..eef5793 100644 --- a/src/types/models/stats.rs +++ b/src/types/models/stats.rs @@ -30,20 +30,27 @@ pub struct Stats { impl Stats { #[tracing::instrument(skip_all)] - pub async fn get_cached(pool: &mut PgConnection) -> Result { + pub async fn get_cached( + pool: &mut PgConnection, + http_client: &Client, + ) -> Result { let mod_stats = Mod::get_stats(&mut *pool).await?; Ok(Stats { total_mod_count: mod_stats.total_count, total_mod_downloads: mod_stats.total_downloads, total_registered_developers: developers::index_count(None, &mut *pool).await?, - total_geode_downloads: Self::get_latest_github_release_download_count(&mut *pool) - .await?, + total_geode_downloads: Self::get_latest_github_release_download_count( + &mut *pool, + http_client, + ) + .await?, }) } #[tracing::instrument(skip_all)] async fn get_latest_github_release_download_count( pool: &mut PgConnection, + http_client: &Client, ) -> Result { // If release stats were fetched less than a day ago, just use cached stats if let Ok((cache_time, total_download_count)) = sqlx::query!( @@ -61,7 +68,7 @@ impl Stats { } // Fetch latest stats - let new = Self::fetch_github_release_stats().await?; + let new = Self::fetch_github_release_stats(http_client).await?; sqlx::query!( "INSERT INTO github_loader_release_stats (total_download_count, latest_loader_version) VALUES ($1, $2)", @@ -74,9 +81,8 @@ impl Stats { Ok(new.0) } - async fn fetch_github_release_stats() -> Result<(i64, String), ApiError> { - let client = Client::new(); - let resp = client + async fn fetch_github_release_stats(http_client: &Client) -> Result<(i64, String), ApiError> { + let resp = http_client .get("https://api.github.com/repos/geode-sdk/geode/releases") .header("Accept", HeaderValue::from_str("application/json").unwrap()) .header("User-Agent", "geode_index") diff --git a/src/types/models/tag.rs b/src/types/models/tag.rs index fffea94..025ad2c 100644 --- a/src/types/models/tag.rs +++ b/src/types/models/tag.rs @@ -63,6 +63,7 @@ impl Tag { pub async fn parse_tags(tags: &str, pool: &mut PgConnection) -> Result, ApiError> { let tags = tags .split(',') + .take(50) .map(|t| t.trim().to_lowercase()) .collect::>();