From 4abe8a53df7db6d1b6d69d950fe0c58995dfda14 Mon Sep 17 00:00:00 2001 From: code002lover Date: Mon, 12 Jan 2026 23:30:36 +0100 Subject: [PATCH] do stuffs --- backend/src/auth.rs | 14 +++- backend/src/csrf.rs | 95 +++++++++++++++++++++++++ backend/src/lib.rs | 5 ++ backend/src/main.rs | 121 ++++++++++++++++++++++---------- backend/src/security_headers.rs | 33 +++++++++ backend/src/validation.rs | 108 ++++++++++++++++++++++++++++ frontend/items.ts | 2 +- frontend/src/api.ts | 10 +++ 8 files changed, 349 insertions(+), 39 deletions(-) create mode 100644 backend/src/csrf.rs create mode 100644 backend/src/security_headers.rs create mode 100644 backend/src/validation.rs diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 23e9264..bc9e229 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,13 +1,14 @@ use crate::auth_persistence::AuthStorage; +use crate::csrf::{CsrfState, set_csrf_cookie}; use crate::items; use crate::proto_utils::Proto; use rocket::State; use rocket::futures::lock::Mutex; +use rocket::http::CookieJar; use std::collections::{HashMap, HashSet}; use uuid::Uuid; pub struct AuthState { - // Map token -> username tokens: Mutex>, storage: AuthStorage, } @@ -65,7 +66,6 @@ impl<'r> rocket::request::FromRequest<'r> for Token { match token { Some(token) => { - // Check if token starts with "Bearer " if let Some(token) = token.strip_prefix("Bearer ") { let state = request.guard::<&State>().await.unwrap(); let tokens = state.tokens.lock().await; @@ -130,7 +130,9 @@ impl<'r> rocket::request::FromRequest<'r> for AdminToken { #[post("/login", data = "")] pub async fn login( state: &State, + csrf_state: &State, user_list: &State>>, + jar: &CookieJar<'_>, request: Proto, ) -> items::LoginResponse { let req = request.into_inner(); @@ -146,6 +148,9 @@ pub async fn login( tokens.insert(token.clone(), req.username.clone()); state.storage.save_tokens(&tokens); + let csrf_token = csrf_state.generate_token(); + set_csrf_cookie(jar, &csrf_token); + return items::LoginResponse { token, success: true, @@ -186,6 +191,8 @@ pub async fn logout( pub async fn get_auth_status( state: &State, admin_state: &State, + csrf_state: &State, + jar: &CookieJar<'_>, request: Proto, ) -> items::AuthStatusResponse { let req = request.into_inner(); @@ -195,6 +202,9 @@ pub async fn get_auth_status( let admins = admin_state.admins.lock().await; let is_admin = crate::store::is_admin(username, &admins); + let csrf_token = csrf_state.generate_token(); + set_csrf_cookie(jar, &csrf_token); + items::AuthStatusResponse { authenticated: true, username: username.clone(), diff --git a/backend/src/csrf.rs b/backend/src/csrf.rs new file mode 100644 index 0000000..d516b93 --- /dev/null +++ b/backend/src/csrf.rs @@ -0,0 +1,95 @@ +use rocket::Build; +use rocket::Request; +use rocket::Rocket; +use rocket::fairing::{Fairing, Info, Kind}; +use rocket::http::{Cookie, CookieJar, SameSite, Status}; +use rocket::request::{FromRequest, Outcome}; +use std::collections::HashSet; +use std::sync::Arc; +use uuid::Uuid; + +const CSRF_COOKIE_NAME: &str = "csrf_token"; +const CSRF_HEADER_NAME: &str = "X-CSRF-Token"; +const CSRF_EXPIRATION_HOURS: u64 = 24; + +#[derive(Clone)] +pub struct CsrfToken(pub String); + +#[derive(Clone)] +pub struct CsrfState { + tokens: Arc>>, +} + +impl CsrfState { + pub fn new() -> Self { + Self { + tokens: Arc::new(std::sync::Mutex::new(HashSet::new())), + } + } + + pub fn generate_token(&self) -> String { + let token = Uuid::new_v4().to_string(); + self.tokens.lock().unwrap().insert(token.clone()); + token + } + + pub fn validate_token(&self, token: &str) -> bool { + self.tokens.lock().unwrap().remove(token) + } +} + +impl Default for CsrfState { + fn default() -> Self { + Self::new() + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for CsrfToken { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let header_token = request.headers().get_one(CSRF_HEADER_NAME); + let cookie_jar = request.guard::<&CookieJar<'_>>().await; + + let cookie_token = match cookie_jar { + Outcome::Success(jar) => jar + .get(CSRF_COOKIE_NAME) + .map(|c: &Cookie<'_>| c.value().to_string()), + _ => None, + }; + + match (header_token, cookie_token) { + (Some(header), Some(cookie)) if header == cookie => { + Outcome::Success(CsrfToken(header.to_string())) + } + _ => Outcome::Error((Status::Forbidden, ())), + } + } +} + +pub struct CsrfFairing; + +#[rocket::async_trait] +impl Fairing for CsrfFairing { + fn info(&self) -> Info { + Info { + name: "CSRF Protection", + kind: Kind::Ignite, + } + } + + async fn on_ignite(&self, rocket: Rocket) -> Result, Rocket> { + Ok(rocket.manage(CsrfState::new())) + } +} + +pub fn set_csrf_cookie(jar: &CookieJar<'_>, token: &str) { + jar.add( + Cookie::build((CSRF_COOKIE_NAME, token.to_owned())) + .http_only(true) + .same_site(SameSite::Strict) + .max_age(rocket::time::Duration::hours(CSRF_EXPIRATION_HOURS as i64)) + .path("/"), + ); +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index f36ae39..bb3d80a 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -7,8 +7,13 @@ pub mod items { pub mod auth; pub mod auth_persistence; +pub mod csrf; pub mod proto_utils; +pub mod security_headers; pub mod store; +pub mod validation; pub use auth::AdminState; +pub use csrf::{CsrfFairing, CsrfState, CsrfToken, set_csrf_cookie}; +pub use security_headers::SecurityHeaders; pub use store::User; diff --git a/backend/src/main.rs b/backend/src/main.rs index eb72fb2..6314fb9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -4,9 +4,12 @@ use rocket::futures::lock::Mutex; use backend::auth; use backend::auth::AdminState; +use backend::csrf::{CsrfFairing, CsrfToken}; use backend::items::{self, Game}; use backend::proto_utils; +use backend::security_headers::SecurityHeaders; use backend::store::{self, User, save_state}; +use backend::validation; #[macro_use] extern crate rocket; @@ -17,6 +20,10 @@ async fn get_user( user_list: &rocket::State>>, name: &str, ) -> Option { + if validation::validate_username(name).is_err() { + return None; + } + let users = user_list.lock().await; users .iter() @@ -41,6 +48,10 @@ async fn get_game( game_list: &rocket::State>>, title: &str, ) -> Option { + if validation::validate_game_title(title).is_err() { + return None; + } + let games = game_list.lock().await; games .iter() @@ -53,12 +64,16 @@ async fn get_games_batch( _token: auth::Token, game_list: &rocket::State>>, req: proto_utils::Proto, -) -> items::GameList { - let games = game_list.lock().await; +) -> Result { let req = req.into_inner(); + + let games_set: std::collections::HashSet = req.games.into_iter().collect(); + validation::validate_game_titles_batch(&games_set)?; + + let games = game_list.lock().await; let mut games = games.clone(); - games.retain(|g| req.games.contains(&g.title)); - items::GameList { games } + games.retain(|g| games_set.contains(&g.title)); + Ok(items::GameList { games }) } #[get("/games")] @@ -72,55 +87,77 @@ async fn get_games( } } -#[post("/game", data = "")] +#[post("/game", data = "", rank = 1)] async fn add_game( _token: auth::Token, + _csrf: CsrfToken, game_list: &rocket::State>>, user_list: &rocket::State>>, game: proto_utils::Proto, -) -> Option { - let mut games = game_list.lock().await; +) -> Result, String> { let mut game = game.into_inner(); game.title = game.title.trim().to_string(); - if game.remote_id == 0 { - return None; + validation::validate_game_title(&game.title)?; + validation::validate_remote_id(game.remote_id)?; + validation::validate_player_count(game.min_players, game.max_players)?; + validation::validate_price(game.price)?; + + if game.title.len() > validation::MAX_GAME_TITLE_TRIMMED_LENGTH { + game.title = game + .title + .chars() + .take(validation::MAX_GAME_TITLE_TRIMMED_LENGTH) + .collect(); } + let users = user_list.lock().await; + let mut games = game_list.lock().await; + if let Some(existing) = games.iter().find(|g| { g.title == game.title || (g.remote_id == game.remote_id && g.source == game.source) }) { - return Some(existing.clone()); + return Ok(Some(existing.clone())); } games.push(game.clone()); games.sort_unstable_by(|g1, g2| g1.title.cmp(&g2.title)); - let users = user_list.lock().await; save_state(&games, &users); - Some(game) + Ok(Some(game)) } -#[patch("/game", data = "")] +#[patch("/game", data = "", rank = 1)] async fn update_game( _token: auth::AdminToken, + _csrf: CsrfToken, game_list: &rocket::State>>, user_list: &rocket::State>>, game: proto_utils::Proto, -) -> Option { - let mut games = game_list.lock().await; - let mut users = user_list.lock().await; +) -> Result, String> { let mut game = game.into_inner(); game.title = game.title.trim().to_string(); - if game.remote_id == 0 { - return None; + validation::validate_game_title(&game.title)?; + validation::validate_remote_id(game.remote_id)?; + validation::validate_player_count(game.min_players, game.max_players)?; + validation::validate_price(game.price)?; + + if game.title.len() > validation::MAX_GAME_TITLE_TRIMMED_LENGTH { + game.title = game + .title + .chars() + .take(validation::MAX_GAME_TITLE_TRIMMED_LENGTH) + .collect(); } + let mut users = user_list.lock().await; + let mut games = game_list.lock().await; + let mut r_existing = None; if let Some(existing) = games.iter_mut().find(|g| { @@ -128,7 +165,6 @@ async fn update_game( }) { if existing.title != game.title { let old_title = existing.title.clone(); - // Update title for every opinion for person in users.iter_mut() { let opinion = person .person @@ -154,16 +190,20 @@ async fn update_game( save_state(&games, &users); - r_existing + Ok(r_existing) } -#[delete("/game/")] +#[delete("/game/<title>", rank = 1)] async fn delete_game( _token: auth::AdminToken, + _csrf: CsrfToken, game_list: &rocket::State<Mutex<Vec<Game>>>, user_list: &rocket::State<Mutex<Vec<User>>>, title: &str, -) -> Option<items::Game> { +) -> Result<Option<items::Game>, String> { + validation::validate_game_title(title)?; + + let mut users = user_list.lock().await; let mut games = game_list.lock().await; if let Some(pos) = games @@ -172,30 +212,29 @@ async fn delete_game( { let game = games.remove(pos); - let mut users = user_list.lock().await; - for person in users.iter_mut() { person.person.opinion.retain_mut(|o| o.title != game.title); } save_state(&games, &users); - return Some(game); + return Ok(Some(game)); } - None + Ok(None) } -#[post("/refresh")] +#[post("/refresh", rank = 1)] async fn refresh_state( _token: auth::AdminToken, + _csrf: CsrfToken, game_list: &rocket::State<Mutex<Vec<Game>>>, user_list: &rocket::State<Mutex<Vec<User>>>, admin_state: &rocket::State<AdminState>, ) -> items::RefreshResponse { if let Some((new_games, new_users)) = store::load_state() { - let mut games = game_list.lock().await; let mut users = user_list.lock().await; + let mut games = game_list.lock().await; let mut admins = admin_state.admins.lock().await; *games = new_games; @@ -214,20 +253,22 @@ async fn refresh_state( } } -#[post("/opinion", data = "<req>")] +#[post("/opinion", data = "<req>", rank = 1)] async fn add_opinion( token: auth::Token, + _csrf: CsrfToken, game_list: &rocket::State<Mutex<Vec<Game>>>, user_list: &rocket::State<Mutex<Vec<User>>>, req: proto_utils::Proto<items::AddOpinionRequest>, -) -> Option<items::Person> { +) -> Result<Option<items::Person>, String> { + validation::validate_username(&token.username)?; + let games = game_list.lock().await; let mut users = user_list.lock().await; let mut result = None; - // Validate game exists if !games.iter().any(|g| g.title == req.game_title) { - return None; + return Err("Game not found".to_string()); } if let Some(user) = users @@ -235,6 +276,8 @@ async fn add_opinion( .find(|u| u.person.name.to_lowercase() == token.username.to_lowercase()) { let req = req.into_inner(); + validation::validate_game_title(&req.game_title)?; + let opinion = items::Opinion { title: req.game_title.clone(), would_play: req.would_play, @@ -260,16 +303,19 @@ async fn add_opinion( if result.is_some() { save_state(&games, &users); } - result + Ok(result) } -#[patch("/opinion", data = "<req>")] +#[patch("/opinion", data = "<req>", rank = 1)] async fn remove_opinion( token: auth::Token, + _csrf: CsrfToken, game_list: &rocket::State<Mutex<Vec<Game>>>, user_list: &rocket::State<Mutex<Vec<User>>>, req: proto_utils::Proto<items::RemoveOpinionRequest>, -) -> Option<items::Person> { +) -> Result<Option<items::Person>, String> { + validation::validate_username(&token.username)?; + let games = game_list.lock().await; let mut users = user_list.lock().await; let mut result = None; @@ -279,6 +325,7 @@ async fn remove_opinion( .find(|u| u.person.name.to_lowercase() == token.username.to_lowercase()) { let req = req.into_inner(); + validation::validate_game_title(&req.game_title)?; if let Some(existing) = user .person @@ -294,7 +341,7 @@ async fn remove_opinion( if result.is_some() { save_state(&games, &users); } - result + Ok(result) } mod cached_option; @@ -447,6 +494,8 @@ async fn main() -> Result<(), std::io::Error> { }); rocket::build() + .attach(SecurityHeaders) + .attach(CsrfFairing) .manage(Mutex::new(user_list)) .manage(auth::AuthState::new()) .manage(auth::AdminState::new()) diff --git a/backend/src/security_headers.rs b/backend/src/security_headers.rs new file mode 100644 index 0000000..7e061dc --- /dev/null +++ b/backend/src/security_headers.rs @@ -0,0 +1,33 @@ +use rocket::fairing::{Fairing, Info, Kind}; +use rocket::http::Header; +use rocket::{Request, Response}; + +pub struct SecurityHeaders; + +#[rocket::async_trait] +impl Fairing for SecurityHeaders { + fn info(&self) -> Info { + Info { + name: "Security Headers", + kind: Kind::Response, + } + } + + async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { + response.set_header(Header::new("X-Content-Type-Options", "nosniff")); + response.set_header(Header::new("X-Frame-Options", "DENY")); + response.set_header(Header::new("X-XSS-Protection", "1; mode=block")); + response.set_header(Header::new( + "Referrer-Policy", + "strict-origin-when-cross-origin", + )); + response.set_header(Header::new( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';", + )); + response.set_header(Header::new( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()", + )); + } +} diff --git a/backend/src/validation.rs b/backend/src/validation.rs new file mode 100644 index 0000000..8e09241 --- /dev/null +++ b/backend/src/validation.rs @@ -0,0 +1,108 @@ +use std::collections::HashSet; + +pub const MAX_USERNAME_LENGTH: usize = 50; +pub const MIN_USERNAME_LENGTH: usize = 2; +pub const MAX_GAME_TITLE_LENGTH: usize = 200; +const MIN_GAME_TITLE_LENGTH: usize = 1; +pub const MAX_GAME_TITLE_TRIMMED_LENGTH: usize = 200; +pub const MAX_PRICE: u32 = 1_000_000; +pub const MAX_PLAYERS: u32 = 10_000; + +const VALID_USERNAME_CHARS: &str = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"; + +pub fn validate_username(username: &str) -> Result<(), String> { + let trimmed = username.trim(); + + if trimmed.is_empty() { + return Err("Username cannot be empty".to_string()); + } + + if trimmed.len() < MIN_USERNAME_LENGTH { + return Err(format!( + "Username must be at least {} characters long", + MIN_USERNAME_LENGTH + )); + } + + if trimmed.len() > MAX_USERNAME_LENGTH { + return Err(format!( + "Username must not exceed {} characters", + MAX_USERNAME_LENGTH + )); + } + + for c in trimmed.chars() { + if !VALID_USERNAME_CHARS.contains(c) { + return Err(format!( + "Username contains invalid character '{}'. Only alphanumeric characters, underscores and hyphens are allowed", + c + )); + } + } + + Ok(()) +} + +pub fn validate_game_title(title: &str) -> Result<(), String> { + if title.trim().is_empty() { + return Err("Game title cannot be empty".to_string()); + } + + if title.len() > MAX_GAME_TITLE_LENGTH { + return Err(format!( + "Game title must not exceed {} characters", + MAX_GAME_TITLE_LENGTH + )); + } + + Ok(()) +} + +pub fn validate_remote_id(remote_id: u64) -> Result<(), String> { + if remote_id == 0 { + return Err("Remote ID cannot be zero".to_string()); + } + + Ok(()) +} + +pub fn validate_player_count(min_players: u32, max_players: u32) -> Result<(), String> { + if min_players == 0 { + return Err("Minimum players must be at least 1".to_string()); + } + + if min_players > max_players { + return Err("Minimum players cannot be greater than maximum players".to_string()); + } + + if max_players > MAX_PLAYERS { + return Err(format!("Maximum players cannot exceed {}", MAX_PLAYERS)); + } + + Ok(()) +} + +pub fn validate_price(price: u32) -> Result<(), String> { + if price > MAX_PRICE { + return Err(format!("Price cannot exceed ${}", MAX_PRICE)); + } + + Ok(()) +} + +pub fn validate_game_titles_batch(titles: &HashSet<String>) -> Result<(), String> { + if titles.is_empty() { + return Ok(()); + } + + if titles.len() > 100 { + return Err("Cannot request more than 100 games at once".to_string()); + } + + for title in titles { + validate_game_title(title)?; + } + + Ok(()) +} diff --git a/frontend/items.ts b/frontend/items.ts index dfb385f..91c08f3 100644 --- a/frontend/items.ts +++ b/frontend/items.ts @@ -1,6 +1,6 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: -// protoc-gen-ts_proto v2.8.3 +// protoc-gen-ts_proto v2.10.1 // protoc v6.33.1 // source: items.proto diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 668e121..9926158 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,16 +1,26 @@ import { AuthStatusRequest, AuthStatusResponse } from "../items"; +export const getCsrfToken = (): string | null => { + const match = document.cookie.match(/csrf_token=([^;]+)/); + return match ? decodeURIComponent(match[1]) : null; +}; + export const apiFetch = async ( url: string, options: RequestInit = {} ): Promise<Response> => { const token = localStorage.getItem("token"); + const csrfToken = getCsrfToken(); const headers = new Headers(options.headers); if (token) { headers.set("Authorization", `Bearer ${token}`); } + if (csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } + const config = { ...options, headers,