diff --git a/CLAUDE.md b/CLAUDE.md index 95d365f..eb708fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,10 @@ Environment variables consumed by the `picloud` binary: | `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. | | `PICLOUD_FILES_ROOT` | `./data` | Filesystem root for `files::*` blob storage (v1.1.5). Bytes live at `/files////`; metadata in Postgres. | | `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` | `104857600` (100 MB) | Per-file hard size cap for `files::*` (v1.1.5). Per-app quotas deferred to v1.2. | +| `PICLOUD_KV_MAX_VALUE_BYTES` | `262144` (256 KB) | Per-key JSON-encoded value cap for `kv::set`. Rejects oversized payloads before authz so anonymous public scripts can't DoS Postgres JSONB columns. | +| `PICLOUD_DOCS_MAX_VALUE_BYTES` | `262144` (256 KB) | Per-document JSON-encoded data cap for `docs::create`/`update`. | +| `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `pubsub::publish_durable`. Prevents one publish from amplifying into N outbox rows × M MB. | +| `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `queue::enqueue`. | ## Out of MVP diff --git a/crates/manager-core/src/docs_service.rs b/crates/manager-core/src/docs_service.rs index 1ae9472..30bb17a 100644 --- a/crates/manager-core/src/docs_service.rs +++ b/crates/manager-core/src/docs_service.rs @@ -34,10 +34,31 @@ use crate::authz::{self, AuthzRepo, Capability}; use crate::docs_filter::{parse_filter, FilterParseError}; use crate::docs_repo::{DocsRepo, DocsRepoError}; +/// Default per-document JSON-encoded data cap (256 KB). Override with +/// `PICLOUD_DOCS_MAX_VALUE_BYTES`. +pub const DEFAULT_DOCS_MAX_VALUE_BYTES: usize = 256 * 1024; + +/// Read `PICLOUD_DOCS_MAX_VALUE_BYTES`; invalid values fall back to the +/// conservative default with a warning. +#[must_use] +pub fn docs_max_value_bytes_from_env() -> usize { + if let Ok(v) = std::env::var("PICLOUD_DOCS_MAX_VALUE_BYTES") { + match v.trim().parse::() { + Ok(n) if n > 0 => return n, + _ => tracing::warn!( + value = %v, + "ignoring invalid PICLOUD_DOCS_MAX_VALUE_BYTES (want a positive integer)" + ), + } + } + DEFAULT_DOCS_MAX_VALUE_BYTES +} + pub struct DocsServiceImpl { repo: Arc, authz: Arc, events: Arc, + max_value_bytes: usize, } impl DocsServiceImpl { @@ -46,14 +67,38 @@ impl DocsServiceImpl { repo: Arc, authz: Arc, events: Arc, + ) -> Self { + Self::with_max_value_bytes(repo, authz, events, DEFAULT_DOCS_MAX_VALUE_BYTES) + } + + #[must_use] + pub fn with_max_value_bytes( + repo: Arc, + authz: Arc, + events: Arc, + max_value_bytes: usize, ) -> Self { Self { repo, authz, events, + max_value_bytes, } } + fn check_data_size(&self, data: &serde_json::Value) -> Result<(), DocsError> { + let encoded_len = serde_json::to_vec(data) + .map(|v| v.len()) + .map_err(|e| DocsError::Backend(format!("encode doc data: {e}")))?; + if encoded_len > self.max_value_bytes { + return Err(DocsError::ValueTooLarge { + limit: self.max_value_bytes, + actual: encoded_len, + }); + } + Ok(()) + } + async fn check_read(&self, cx: &SdkCallCx) -> Result<(), DocsError> { authz::script_gate( &*self.authz, @@ -116,6 +161,7 @@ impl DocsService for DocsServiceImpl { ) -> Result { validate_collection(collection)?; validate_data(&data)?; + self.check_data_size(&data)?; self.check_write(cx).await?; let row = self .repo @@ -193,6 +239,7 @@ impl DocsService for DocsServiceImpl { ) -> Result<(), DocsError> { validate_collection(collection)?; validate_data(&data)?; + self.check_data_size(&data)?; self.check_write(cx).await?; let previous = self .repo diff --git a/crates/manager-core/src/kv_service.rs b/crates/manager-core/src/kv_service.rs index 4514227..d6134d2 100644 --- a/crates/manager-core/src/kv_service.rs +++ b/crates/manager-core/src/kv_service.rs @@ -25,10 +25,32 @@ use picloud_shared::{ use crate::authz::{self, AuthzRepo, Capability}; use crate::kv_repo::{KvRepo, KvRepoError}; +/// Default per-key JSON-encoded value cap (256 KB). Override with +/// `PICLOUD_KV_MAX_VALUE_BYTES`. +pub const DEFAULT_KV_MAX_VALUE_BYTES: usize = 256 * 1024; + +/// Read `PICLOUD_KV_MAX_VALUE_BYTES`; invalid values fall back to the +/// conservative default with a warning. Public so the picloud binary can +/// thread it through `KvServiceImpl::new`. +#[must_use] +pub fn kv_max_value_bytes_from_env() -> usize { + if let Ok(v) = std::env::var("PICLOUD_KV_MAX_VALUE_BYTES") { + match v.trim().parse::() { + Ok(n) if n > 0 => return n, + _ => tracing::warn!( + value = %v, + "ignoring invalid PICLOUD_KV_MAX_VALUE_BYTES (want a positive integer)" + ), + } + } + DEFAULT_KV_MAX_VALUE_BYTES +} + pub struct KvServiceImpl { repo: Arc, authz: Arc, events: Arc, + max_value_bytes: usize, } impl KvServiceImpl { @@ -37,11 +59,22 @@ impl KvServiceImpl { repo: Arc, authz: Arc, events: Arc, + ) -> Self { + Self::with_max_value_bytes(repo, authz, events, DEFAULT_KV_MAX_VALUE_BYTES) + } + + #[must_use] + pub fn with_max_value_bytes( + repo: Arc, + authz: Arc, + events: Arc, + max_value_bytes: usize, ) -> Self { Self { repo, authz, events, + max_value_bytes, } } @@ -102,6 +135,15 @@ impl KvService for KvServiceImpl { value: serde_json::Value, ) -> Result<(), KvError> { validate_collection(collection)?; + let encoded_len = serde_json::to_vec(&value) + .map(|v| v.len()) + .map_err(|e| KvError::Backend(format!("encode value: {e}")))?; + if encoded_len > self.max_value_bytes { + return Err(KvError::ValueTooLarge { + limit: self.max_value_bytes, + actual: encoded_len, + }); + } self.check_write(cx).await?; let previous = self .repo diff --git a/crates/manager-core/src/pubsub_service.rs b/crates/manager-core/src/pubsub_service.rs index 5d3ff20..4b35ee2 100644 --- a/crates/manager-core/src/pubsub_service.rs +++ b/crates/manager-core/src/pubsub_service.rs @@ -70,6 +70,26 @@ fn load_i64(dst: &mut i64, key: &str) { } } +/// Default per-message JSON-encoded payload cap (256 KB). Override with +/// `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES`. +pub const DEFAULT_PUBSUB_MAX_MESSAGE_BYTES: usize = 256 * 1024; + +/// Read `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES`; invalid values fall back to +/// the conservative default with a warning. +#[must_use] +pub fn pubsub_max_message_bytes_from_env() -> usize { + if let Ok(v) = std::env::var("PICLOUD_PUBSUB_MAX_MESSAGE_BYTES") { + match v.trim().parse::() { + Ok(n) if n > 0 => return n, + _ => tracing::warn!( + value = %v, + "ignoring invalid PICLOUD_PUBSUB_MAX_MESSAGE_BYTES (want a positive integer)" + ), + } + } + DEFAULT_PUBSUB_MAX_MESSAGE_BYTES +} + pub struct PubsubServiceImpl { repo: Arc, authz: Arc, @@ -80,6 +100,7 @@ pub struct PubsubServiceImpl { topics: Option>, secrets: Option>, token_config: SubscriberTokenConfig, + max_message_bytes: usize, } impl PubsubServiceImpl { @@ -92,9 +113,16 @@ impl PubsubServiceImpl { topics: None, secrets: None, token_config: SubscriberTokenConfig::conservative(), + max_message_bytes: DEFAULT_PUBSUB_MAX_MESSAGE_BYTES, } } + #[must_use] + pub fn with_max_message_bytes(mut self, n: usize) -> Self { + self.max_message_bytes = n; + self + } + /// Attach the v1.1.6 realtime surface: the in-process broadcaster /// (publish fan-out to SSE subscribers), the topic registry + /// app-secrets repo (subscriber-token minting), and the TTL config. @@ -142,6 +170,17 @@ impl PubsubService for PubsubServiceImpl { if topic.trim().is_empty() { return Err(PubsubError::EmptyTopic); } + // Reject oversized messages BEFORE the authz check so an + // anonymous public-script DoS doesn't pay the membership lookup. + let encoded_len = serde_json::to_vec(&message) + .map(|v| v.len()) + .map_err(|e| PubsubError::Rejected(format!("encode message: {e}")))?; + if encoded_len > self.max_message_bytes { + return Err(PubsubError::MessageTooLarge { + limit: self.max_message_bytes, + actual: encoded_len, + }); + } self.check_publish(cx).await?; // `published_at` is stamped once on the manager side so every diff --git a/crates/manager-core/src/queue_service.rs b/crates/manager-core/src/queue_service.rs index 9a4b6f8..0f3eed1 100644 --- a/crates/manager-core/src/queue_service.rs +++ b/crates/manager-core/src/queue_service.rs @@ -15,16 +15,50 @@ use picloud_shared::{EnqueueOpts, QueueError, QueueMessageId, QueueService, SdkC use crate::authz::{self, AuthzRepo, Capability}; use crate::queue_repo::{NewQueueMessage, QueueRepo}; +/// Default per-message JSON-encoded payload cap (256 KB). Override with +/// `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES`. +pub const DEFAULT_QUEUE_MAX_PAYLOAD_BYTES: usize = 256 * 1024; + +/// Read `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES`; invalid values fall back to +/// the conservative default with a warning. +#[must_use] +pub fn queue_max_payload_bytes_from_env() -> usize { + if let Ok(v) = std::env::var("PICLOUD_QUEUE_MAX_PAYLOAD_BYTES") { + match v.trim().parse::() { + Ok(n) if n > 0 => return n, + _ => tracing::warn!( + value = %v, + "ignoring invalid PICLOUD_QUEUE_MAX_PAYLOAD_BYTES (want a positive integer)" + ), + } + } + DEFAULT_QUEUE_MAX_PAYLOAD_BYTES +} + /// Production impl: authz gate → repo. Trivial wrapper. pub struct QueueServiceImpl { repo: Arc, authz: Arc, + max_payload_bytes: usize, } impl QueueServiceImpl { #[must_use] pub fn new(repo: Arc, authz: Arc) -> Self { - Self { repo, authz } + Self::with_max_payload_bytes(repo, authz, DEFAULT_QUEUE_MAX_PAYLOAD_BYTES) + } + + #[must_use] + pub fn with_max_payload_bytes( + repo: Arc, + authz: Arc, + max_payload_bytes: usize, + ) -> Self { + Self { + repo, + authz, + max_payload_bytes, + } } } @@ -40,6 +74,17 @@ impl QueueService for QueueServiceImpl { if queue_name.is_empty() { return Err(QueueError::EmptyName); } + // Reject oversized payloads BEFORE the authz check so an + // anonymous public-script DoS doesn't pay the membership lookup. + let encoded_len = serde_json::to_vec(&payload) + .map(|v| v.len()) + .map_err(|e| QueueError::Rejected(format!("encode payload: {e}")))?; + if encoded_len > self.max_payload_bytes { + return Err(QueueError::PayloadTooLarge { + limit: self.max_payload_bytes, + actual: encoded_len, + }); + } let max_attempts = opts.max_attempts.unwrap_or(3); if !(1..=20).contains(&max_attempts) { return Err(QueueError::InvalidOpts( @@ -311,6 +356,24 @@ mod tests { assert!(captured.deliver_after.is_none()); } + #[tokio::test] + async fn oversized_payload_is_rejected_before_authz() { + let repo = Arc::new(CapturingRepo { + last: tokio::sync::Mutex::new(None), + }); + let svc = QueueServiceImpl::with_max_payload_bytes(repo, Arc::new(AlwaysAllowAuthz), 16); + let cx = anon_cx(); + let payload = serde_json::json!({ "blob": "x".repeat(200) }); + let err = svc + .enqueue(&cx, "jobs", payload, EnqueueOpts::default()) + .await + .unwrap_err(); + assert!( + matches!(err, QueueError::PayloadTooLarge { .. }), + "expected PayloadTooLarge, got {err:?}" + ); + } + struct DenyAllAuthz; #[async_trait] impl AuthzRepo for DenyAllAuthz { diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 20380ef..54bdbb7 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -144,12 +144,17 @@ pub async fn build_app( trigger_repo.clone(), outbox_repo.clone(), )); - let kv: Arc = - Arc::new(KvServiceImpl::new(kv_repo, authz.clone(), events.clone())); - let docs: Arc = Arc::new(DocsServiceImpl::new( + let kv: Arc = Arc::new(KvServiceImpl::with_max_value_bytes( + kv_repo, + authz.clone(), + events.clone(), + picloud_manager_core::kv_service::kv_max_value_bytes_from_env(), + )); + let docs: Arc = Arc::new(DocsServiceImpl::with_max_value_bytes( docs_repo, authz.clone(), events.clone(), + picloud_manager_core::docs_service::docs_max_value_bytes_from_env(), )); let dl_service: Arc = Arc::new(PostgresDeadLetterService::new( dl_repo.clone(), @@ -207,12 +212,16 @@ pub async fn build_app( // to in-process SSE subscribers. let pubsub_repo = Arc::new(PostgresPubsubRepo::new(pool.clone())); let pubsub: Arc = Arc::new( - PubsubServiceImpl::new(pubsub_repo, authz.clone()).with_realtime( - broadcaster.clone(), - topic_repo.clone(), - app_secrets_repo.clone(), - SubscriberTokenConfig::from_env(), - ), + PubsubServiceImpl::new(pubsub_repo, authz.clone()) + .with_max_message_bytes( + picloud_manager_core::pubsub_service::pubsub_max_message_bytes_from_env(), + ) + .with_realtime( + broadcaster.clone(), + topic_repo.clone(), + app_secrets_repo.clone(), + SubscriberTokenConfig::from_env(), + ), ); // v1.1.7 encrypted per-app secrets. Values are AES-256-GCM-sealed // with the process master key before they touch Postgres; the repo @@ -267,11 +276,13 @@ pub async fn build_app( let queue_repo: Arc = Arc::new( picloud_manager_core::queue_repo::PostgresQueueRepo::new(pool.clone()), ); - let queue: Arc = - Arc::new(picloud_manager_core::queue_service::QueueServiceImpl::new( + let queue: Arc = Arc::new( + picloud_manager_core::queue_service::QueueServiceImpl::with_max_payload_bytes( queue_repo.clone(), authz.clone(), - )); + picloud_manager_core::queue_service::queue_max_payload_bytes_from_env(), + ), + ); // Route table created early (before Services) so InvokeServiceImpl // can use it for path resolution. It's populated below from the // route_repo, then re-populated whenever the admin layer writes diff --git a/crates/shared/src/docs.rs b/crates/shared/src/docs.rs index b6372ce..bee921c 100644 --- a/crates/shared/src/docs.rs +++ b/crates/shared/src/docs.rs @@ -252,6 +252,11 @@ pub enum DocsError { #[error("forbidden")] Forbidden, + /// JSON-encoded document data exceeded the per-app `max_value_bytes` + /// cap. Configured via `PICLOUD_DOCS_MAX_VALUE_BYTES`. + #[error("docs: data too large ({actual} bytes; limit {limit})")] + ValueTooLarge { limit: usize, actual: usize }, + /// Anything else — Postgres unavailable, serialization failure, /// etc. The string is safe to surface to a script. #[error("docs backend error: {0}")] diff --git a/crates/shared/src/kv.rs b/crates/shared/src/kv.rs index 57dec3e..a0cb4d9 100644 --- a/crates/shared/src/kv.rs +++ b/crates/shared/src/kv.rs @@ -133,6 +133,12 @@ pub enum KvError { #[error("forbidden")] Forbidden, + /// JSON-encoded value exceeded the per-app `max_value_bytes` cap. + /// Defends Postgres JSONB columns from anonymous-public-script DoS; + /// the limit is configurable via `PICLOUD_KV_MAX_VALUE_BYTES`. + #[error("kv: value too large ({actual} bytes; limit {limit})")] + ValueTooLarge { limit: usize, actual: usize }, + /// Anything else — Postgres unavailable, serialization failure, /// etc. The string is safe to surface to a script. #[error("kv backend error: {0}")] diff --git a/crates/shared/src/pubsub.rs b/crates/shared/src/pubsub.rs index 4cce6d1..6675b33 100644 --- a/crates/shared/src/pubsub.rs +++ b/crates/shared/src/pubsub.rs @@ -73,6 +73,13 @@ pub enum PubsubError { #[error("pubsub rejected: {0}")] Rejected(String), + /// JSON-encoded message exceeded the configured `max_message_bytes` + /// cap (`PICLOUD_PUBSUB_MAX_MESSAGE_BYTES`). Defends against an + /// anonymous public script amplifying one publish into N outbox rows + /// × payload bytes. + #[error("pubsub: message too large ({actual} bytes; limit {limit})")] + MessageTooLarge { limit: usize, actual: usize }, + /// A `pubsub::subscriber_token` mint was rejected (empty topics, /// unregistered topic, ttl out of range, anonymous caller). The /// string is the full user-facing message; the SDK surfaces it diff --git a/crates/shared/src/queue.rs b/crates/shared/src/queue.rs index f621a04..740654b 100644 --- a/crates/shared/src/queue.rs +++ b/crates/shared/src/queue.rs @@ -78,6 +78,12 @@ pub enum QueueError { #[error("{0}")] InvalidOpts(String), + /// JSON-encoded payload exceeded the configured `max_payload_bytes` + /// cap (`PICLOUD_QUEUE_MAX_PAYLOAD_BYTES`). Defends against script + /// queue::enqueue calls filling disk with multi-MB payloads. + #[error("queue: payload too large ({actual} bytes; limit {limit})")] + PayloadTooLarge { limit: usize, actual: usize }, + /// Backend failure (Postgres unavailable, etc.). Named `Backend` to /// align with KvError / DocsError / FilesError / SecretsError. #[error("queue backend error: {0}")]