fix: message sanitize, CORS policy #5

Merged
zacheryasc merged 1 commit from cfuzz into master 2026-02-08 19:24:48 +00:00
3 changed files with 70 additions and 22 deletions

View file

@ -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" }

View file

@ -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<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 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]
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()
.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<BoxedMessage>) -> (StatusCode, Json<UserCreated>) {
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<BoxedMessage>) -> (StatusCode, Json<UserCreated>) {
// 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<BoxedMessage>) -> (StatusCode, Json<Use
(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();
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>> {

View file

@ -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();