diff --git a/docs/api.md b/docs/api.md index 91c2231..fb12e34 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,6 +2,11 @@ All endpoints are mounted under `/api`. Bodies and responses are JSON unless noted otherwise. +JSON request bodies are capped at 8 KiB by default. `/api/register` accepts up +to 128 KiB so oversized token values can return field-level validation errors, +but the stored `token` field is still capped at 4096 bytes. Requests over the +active body cap return `413 Payload Too Large`. + | Method | Path | Purpose | |--------|------------------|--------------------------------------------------------| | GET | `/api/health` | Liveness probe | @@ -77,7 +82,7 @@ Request: | Field | Type | Description | |-----------------|--------|------------------------------------------------------------------------------------------------------------------------| | `trade_pubkey` | string | 64 hex characters | -| `token` | string | FCM device token, or UnifiedPush endpoint URL | +| `token` | string | FCM device token, or UnifiedPush endpoint URL; 1 to 4096 bytes | | `platform` | string | `"android"` or `"ios"` | | `mostro_pubkey` | string | 64 hex characters. Optional on the wire; required when the trusted-instance whitelist is non-empty (see below). | @@ -104,6 +109,7 @@ Possible validation errors: - `trade_pubkey` not 64 hex characters - `token` empty +- `token` longer than 4096 bytes - `platform` not `"android"` or `"ios"` - `mostro_pubkey` present but not 64 hex characters diff --git a/src/api/routes.rs b/src/api/routes.rs index 209261a..605c782 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -12,6 +12,18 @@ use crate::push::PushDispatcher; use crate::store::{Platform, TokenStore, TokenStoreStats}; use crate::utils::log_pubkey::log_pubkey; +pub const JSON_PAYLOAD_LIMIT_BYTES: usize = 8 * 1024; +pub const REGISTER_JSON_PAYLOAD_LIMIT_BYTES: usize = 128 * 1024; +pub const REGISTER_TOKEN_MAX_BYTES: usize = 4096; + +pub fn json_config() -> web::JsonConfig { + web::JsonConfig::default().limit(JSON_PAYLOAD_LIMIT_BYTES) +} + +fn register_json_config() -> web::JsonConfig { + web::JsonConfig::default().limit(REGISTER_JSON_PAYLOAD_LIMIT_BYTES) +} + /// Request for registering a plaintext token (Phase 3 - unencrypted). /// /// `mostro_pubkey` is the hex pubkey (64 chars) of the Mostro instance the @@ -71,7 +83,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) { web::scope("/api") .route("/health", web::get().to(health_check)) .route("/status", web::get().to(status)) - .route("/register", web::post().to(register_token)) + .service( + web::resource("/register") + .app_data(register_json_config()) + .route(web::post().to(register_token)), + ) .route("/unregister", web::post().to(unregister_token)) .route("/info", web::get().to(server_info)) .service( @@ -140,6 +156,15 @@ async fn register_token( }); } + if req.token.len() > REGISTER_TOKEN_MAX_BYTES { + warn!("Token too long"); + return HttpResponse::BadRequest().json(RegisterResponse { + success: false, + message: format!("Token too long (maximum {REGISTER_TOKEN_MAX_BYTES} bytes)"), + platform: None, + }); + } + // Parse platform let platform = match req.platform.to_lowercase().as_str() { "android" => Platform::Android, @@ -273,6 +298,7 @@ async fn unregister_token( #[cfg(test)] mod tests { + use super::JSON_PAYLOAD_LIMIT_BYTES; use crate::api::test_support::{ build_test_actix_app, make_app_state_with_whitelist, make_test_components, make_test_components_with_trusted_whitelist, make_test_components_with_whitelist_disabled, @@ -334,6 +360,70 @@ mod tests { ); } + #[actix_web::test] + async fn register_one_megabyte_payload_returns_413() { + let c = make_test_components(); + let app = atest::init_service(build_test_actix_app(c)).await; + let body = serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "t".repeat(1024 * 1024), + "platform": "android" + }) + .to_string(); + + let req = atest::TestRequest::post() + .uri("/api/register") + .insert_header(("Content-Type", "application/json")) + .set_payload(body) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[actix_web::test] + async fn register_hundred_kb_token_returns_400() { + let c = make_test_components(); + let app = atest::init_service(build_test_actix_app(c)).await; + let body = serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "t".repeat(100 * 1024), + "platform": "android" + }) + .to_string(); + + let req = atest::TestRequest::post() + .uri("/api/register") + .insert_header(("Content-Type", "application/json")) + .set_payload(body) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = atest::read_body(resp).await; + let body_str = std::str::from_utf8(&body).unwrap(); + assert_eq!( + body_str, + r#"{"success":false,"message":"Token too long (maximum 4096 bytes)"}"# + ); + } + + #[actix_web::test] + async fn json_payload_limit_applies_to_non_register_routes() { + let c = make_test_components(); + let app = atest::init_service(build_test_actix_app(c)).await; + let body = serde_json::json!({ + "trade_pubkey": "1".repeat(JSON_PAYLOAD_LIMIT_BYTES) + }) + .to_string(); + + let req = atest::TestRequest::post() + .uri("/api/unregister") + .insert_header(("Content-Type", "application/json")) + .set_payload(body) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + /// VERIFY-02: /api/unregister "not found" body is BYTE-IDENTICAL. #[actix_web::test] async fn unregister_not_found_body_is_byte_identical() { diff --git a/src/api/test_support.rs b/src/api/test_support.rs index 1de3fe4..444512f 100644 --- a/src/api/test_support.rs +++ b/src/api/test_support.rs @@ -11,7 +11,7 @@ use rand::RngCore; use crate::api::rate_limit::{ PerIpLimiter, PerPubkeyLimiter, TrustProxyHeaders, IP_BURST, PUBKEY_BURST, }; -use crate::api::routes::{configure, AppState}; +use crate::api::routes::{configure, json_config, AppState}; use crate::push::{PushDispatcher, PushService}; use crate::store::{Platform, TokenStore}; @@ -199,6 +199,7 @@ pub fn build_test_actix_app( App::new() .app_data(web::Data::new(c.state)) .app_data(web::Data::new(c.per_ip_limiter)) + .app_data(json_config()) // Existing rate-limit tests inject Fly-Client-IP / X-Forwarded-For // and expect the middleware to honour them; mirror that by enabling // the proxy-trust flag here. Tests covering the default-false bypass diff --git a/src/main.rs b/src/main.rs index 28bd8b5..b14abd5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ mod utils; mod crypto; use api::rate_limit::{PerIpLimiter, PerPubkeyLimiter, TrustProxyHeaders, IP_BURST, PUBKEY_BURST}; -use api::routes::AppState; +use api::routes::{json_config, AppState}; use config::Config; use nostr::NostrListener; use push::{FcmPush, PushDispatcher, PushService, UnifiedPushService}; @@ -232,6 +232,7 @@ async fn main() -> std::io::Result<()> { .app_data(web::Data::new(app_state.clone())) .app_data(web::Data::new(per_ip_limiter.clone())) .app_data(web::Data::new(trust_proxy_headers)) + .app_data(json_config()) .configure(api::routes::configure) }) .bind(server_addr)?