From 4200ca77df20fa003628b8d3043360659d6e4c9c Mon Sep 17 00:00:00 2001 From: zacheryasc Date: Sun, 8 Feb 2026 19:24:47 +0000 Subject: [PATCH] fix: message sanitize, CORS policy (#5) --- crates/server/Cargo.toml | 2 +- crates/server/bin/main.rs | 82 ++++++++++++++++++++++++++----- routes/contact/message/index.html | 8 --- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 5972592..ddc5328 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -10,7 +10,7 @@ path = "bin/main.rs" [dependencies] axum = "0.8.7" tokio = { version = "1.48.0", features = ["full"] } -tower-http = { version = "0.6.6", features = ["fs"] } +tower-http = { version = "0.6.6", features = ["fs", "cors", "limit"] } serde_json = "1.0.145" message-tools = { path = "../message-tools" } diff --git a/crates/server/bin/main.rs b/crates/server/bin/main.rs index a28b2bb..a9fa515 100644 --- a/crates/server/bin/main.rs +++ b/crates/server/bin/main.rs @@ -1,6 +1,6 @@ use axum::{ Json, Router, - http::StatusCode, + http::{Method, StatusCode}, response::Html, routing::{get, post}, }; @@ -10,17 +10,22 @@ use std::{ fs::OpenOptions, io::{BufRead, Read}, path::{Path, PathBuf}, + time::Duration, }; use tokio::io::AsyncWriteExt; -use tower_http::services::ServeDir; +use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer, services::ServeDir}; const PUBKEY_PATH: &str = "./pubkeys/public_keys.json"; const MESSAGE_STORAGE: &str = "./messages"; lazy_static::lazy_static! { static ref PUBLIC_KEYS: Vec> = load_public_keys(PUBKEY_PATH).expect("static path"); + static ref PUBLISH_LIMITER: std::sync::Mutex<(std::time::Instant, u32)> = + std::sync::Mutex::new((std::time::Instant::now(), 0)); } +const MAX_PUBLISHES_PER_MINUTE: u32 = 10; + type Error = Box; type Result = std::result::Result; @@ -59,16 +64,31 @@ fn load_public_keys(path: impl AsRef) -> std::io::Result>> { #[tokio::main] async fn main() -> Result<()> { - // build our application with a single route + let cors = CorsLayer::new() + .allow_origin([ + "https://zachery.lol" + .parse::() + .unwrap(), + "https://www.zachery.lol" + .parse::() + .unwrap(), + ]) + .allow_methods([Method::GET, Method::POST]) + .allow_headers([axum::http::header::CONTENT_TYPE]); + let app = Router::new() .route("/", get(serve_path("./routes/root/index.html")?)) .route("/who", get(serve_path("./routes/who/index.html")?)) .route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS))) .route("/contact", get(serve_path("./routes/contact/index.html")?)) .route("/contact/message", get(serve_path("./routes/contact/message/index.html")?)) - .route("/api/publish", post(publish_message)) + .route( + "/api/publish", + post(publish_message).layer(RequestBodyLimitLayer::new(64 * 1024)), + ) .nest_service("/gossip-dashboard", ServeDir::new("./routes/gossip-dashboard")) - .nest_service("/routes", ServeDir::new("./routes")); + .nest_service("/routes", ServeDir::new("./routes")) + .layer(cors); let addr = "0.0.0.0:3000"; let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); @@ -84,13 +104,40 @@ struct UserCreated { tag: String, } -// FIXME: sanitation of boxed messages -async fn publish_message(Json(msg): Json) -> (StatusCode, Json) { - let tag = msg.tag(); // Capture tag early +/// Strip everything except ASCII alphanumerics, dashes, and underscores +/// so a tag can never escape the storage directory. +fn sanitize_tag(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect() +} - // 1. Define the full *file* path: e.g., "MESSAGE_STORAGE/some_tag/.json" - let file_path = PathBuf::from(MESSAGE_STORAGE) - .join(format!("{tag}.json")); // The file inside that directory +async fn publish_message(Json(msg): Json) -> (StatusCode, Json) { + // Rate limit: sliding window of MAX_PUBLISHES_PER_MINUTE + { + let mut limiter = PUBLISH_LIMITER.lock().unwrap(); + let now = std::time::Instant::now(); + if now.duration_since(limiter.0) > Duration::from_secs(60) { + *limiter = (now, 0); + } + if limiter.1 >= MAX_PUBLISHES_PER_MINUTE { + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(UserCreated { + tag: String::new(), + }), + ); + } + limiter.1 += 1; + } + + let tag = sanitize_tag(&msg.tag()); + + if tag.is_empty() { + return (StatusCode::BAD_REQUEST, Json(UserCreated { tag })); + } + + let file_path = PathBuf::from(MESSAGE_STORAGE).join(format!("{tag}.json")); // 3. Create all necessary parent directories recursively (async operation) if let Err(e) = tokio::fs::create_dir_all(&MESSAGE_STORAGE).await { @@ -128,9 +175,18 @@ async fn publish_message(Json(msg): Json) -> (StatusCode, Json>) -> String { +fn select_key(pks: &[Vec]) -> (StatusCode, String) { + if pks.is_empty() { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "no keys available".to_string(), + ); + } let idx = getrandom::u64().expect("random integer") as usize % pks.len(); - serde_json::to_string(&pks[idx]).expect("serialize vec of bytes") + ( + StatusCode::OK, + serde_json::to_string(&pks[idx]).expect("serialize vec of bytes"), + ) } fn serve_path(path: impl AsRef) -> Result> { diff --git a/routes/contact/message/index.html b/routes/contact/message/index.html index 37b5977..bdebb4a 100644 --- a/routes/contact/message/index.html +++ b/routes/contact/message/index.html @@ -122,8 +122,6 @@ await wasm_bindgen(); await fetchPublicKey(); - console.log(`Server pk: ${serverPublicKey}`); - const form = document.getElementById("message-form"); form.addEventListener("submit", (event) => { event.preventDefault(); @@ -148,7 +146,6 @@ } async function submit() { - console.log("begin"); const user = document.getElementById("name").value; const password = document.getElementById("password").value; const message = document.getElementById("message").value; @@ -162,7 +159,6 @@ }; const keyBytes = make_key_bytes(JSON.stringify(makeKeyBytesArgs)); - console.log(keyBytes); const boxMessageArgs = { user: String(user), @@ -171,10 +167,8 @@ remote_pk: [...serverPublicKey], plaintext: String(message), }; - console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs)); const boxed = box_message(JSON.stringify(boxMessageArgs)); const boxedObj = JSON.parse(boxed); - console.log(boxedObj); try { const response = await fetch("/api/publish", { @@ -190,7 +184,6 @@ } const result = await response.json(); - console.log("Success:", result); alert("Sent!"); location.reload(); } catch (error) { @@ -198,7 +191,6 @@ alert("Failed to send message: " + error.message); } - console.log("End"); } run();