Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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). |

Expand All @@ -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

Expand Down
92 changes: 91 additions & 1 deletion src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion src/api/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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)?
Expand Down
Loading