fix: message sanitize, CORS policy (#5)

This commit is contained in:
zacheryasc 2026-02-08 19:24:47 +00:00
parent d2e9f37e6e
commit 4200ca77df
3 changed files with 70 additions and 22 deletions

View file

@ -10,7 +10,7 @@ path = "bin/main.rs"
[dependencies] [dependencies]
axum = "0.8.7" axum = "0.8.7"
tokio = { version = "1.48.0", features = ["full"] } 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" serde_json = "1.0.145"
message-tools = { path = "../message-tools" } message-tools = { path = "../message-tools" }

View file

@ -1,6 +1,6 @@
use axum::{ use axum::{
Json, Router, Json, Router,
http::StatusCode, http::{Method, StatusCode},
response::Html, response::Html,
routing::{get, post}, routing::{get, post},
}; };
@ -10,17 +10,22 @@ use std::{
fs::OpenOptions, fs::OpenOptions,
io::{BufRead, Read}, io::{BufRead, Read},
path::{Path, PathBuf}, path::{Path, PathBuf},
time::Duration,
}; };
use tokio::io::AsyncWriteExt; 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 PUBKEY_PATH: &str = "./pubkeys/public_keys.json";
const MESSAGE_STORAGE: &str = "./messages"; const MESSAGE_STORAGE: &str = "./messages";
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref PUBLIC_KEYS: Vec<Vec<u8>> = load_public_keys(PUBKEY_PATH).expect("static path"); static ref PUBLIC_KEYS: Vec<Vec<u8>> = 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<dyn std::error::Error>; type Error = Box<dyn std::error::Error>;
type Result<T> = std::result::Result<T, Error>; type Result<T> = std::result::Result<T, Error>;
@ -59,16 +64,31 @@ fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// build our application with a single route let cors = CorsLayer::new()
.allow_origin([
"https://zachery.lol"
.parse::<axum::http::HeaderValue>()
.unwrap(),
"https://www.zachery.lol"
.parse::<axum::http::HeaderValue>()
.unwrap(),
])
.allow_methods([Method::GET, Method::POST])
.allow_headers([axum::http::header::CONTENT_TYPE]);
let app = Router::new() let app = Router::new()
.route("/", get(serve_path("./routes/root/index.html")?)) .route("/", get(serve_path("./routes/root/index.html")?))
.route("/who", get(serve_path("./routes/who/index.html")?)) .route("/who", get(serve_path("./routes/who/index.html")?))
.route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS))) .route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS)))
.route("/contact", get(serve_path("./routes/contact/index.html")?)) .route("/contact", get(serve_path("./routes/contact/index.html")?))
.route("/contact/message", get(serve_path("./routes/contact/message/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("/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 addr = "0.0.0.0:3000";
let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
@ -84,13 +104,40 @@ struct UserCreated {
tag: String, tag: String,
} }
// FIXME: sanitation of boxed messages /// Strip everything except ASCII alphanumerics, dashes, and underscores
async fn publish_message(Json(msg): Json<BoxedMessage>) -> (StatusCode, Json<UserCreated>) { /// so a tag can never escape the storage directory.
let tag = msg.tag(); // Capture tag early 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" async fn publish_message(Json(msg): Json<BoxedMessage>) -> (StatusCode, Json<UserCreated>) {
let file_path = PathBuf::from(MESSAGE_STORAGE) // Rate limit: sliding window of MAX_PUBLISHES_PER_MINUTE
.join(format!("{tag}.json")); // The file inside that directory {
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) // 3. Create all necessary parent directories recursively (async operation)
if let Err(e) = tokio::fs::create_dir_all(&MESSAGE_STORAGE).await { if let Err(e) = tokio::fs::create_dir_all(&MESSAGE_STORAGE).await {
@ -128,9 +175,18 @@ async fn publish_message(Json(msg): Json<BoxedMessage>) -> (StatusCode, Json<Use
(StatusCode::CREATED, Json(UserCreated { tag })) (StatusCode::CREATED, Json(UserCreated { tag }))
} }
fn select_key(pks: &Vec<Vec<u8>>) -> String { fn select_key(pks: &[Vec<u8>]) -> (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(); 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<std::path::Path>) -> Result<Html<String>> { fn serve_path(path: impl AsRef<std::path::Path>) -> Result<Html<String>> {

View file

@ -122,8 +122,6 @@
await wasm_bindgen(); await wasm_bindgen();
await fetchPublicKey(); await fetchPublicKey();
console.log(`Server pk: ${serverPublicKey}`);
const form = document.getElementById("message-form"); const form = document.getElementById("message-form");
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
@ -148,7 +146,6 @@
} }
async function submit() { async function submit() {
console.log("begin");
const user = document.getElementById("name").value; const user = document.getElementById("name").value;
const password = document.getElementById("password").value; const password = document.getElementById("password").value;
const message = document.getElementById("message").value; const message = document.getElementById("message").value;
@ -162,7 +159,6 @@
}; };
const keyBytes = make_key_bytes(JSON.stringify(makeKeyBytesArgs)); const keyBytes = make_key_bytes(JSON.stringify(makeKeyBytesArgs));
console.log(keyBytes);
const boxMessageArgs = { const boxMessageArgs = {
user: String(user), user: String(user),
@ -171,10 +167,8 @@
remote_pk: [...serverPublicKey], remote_pk: [...serverPublicKey],
plaintext: String(message), plaintext: String(message),
}; };
console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs));
const boxed = box_message(JSON.stringify(boxMessageArgs)); const boxed = box_message(JSON.stringify(boxMessageArgs));
const boxedObj = JSON.parse(boxed); const boxedObj = JSON.parse(boxed);
console.log(boxedObj);
try { try {
const response = await fetch("/api/publish", { const response = await fetch("/api/publish", {
@ -190,7 +184,6 @@
} }
const result = await response.json(); const result = await response.json();
console.log("Success:", result);
alert("Sent!"); alert("Sent!");
location.reload(); location.reload();
} catch (error) { } catch (error) {
@ -198,7 +191,6 @@
alert("Failed to send message: " + error.message); alert("Failed to send message: " + error.message);
} }
console.log("End");
} }
run(); run();