diff --git a/crates/manager-core/src/docs_service.rs b/crates/manager-core/src/docs_service.rs index 4ab4211..93233fb 100644 --- a/crates/manager-core/src/docs_service.rs +++ b/crates/manager-core/src/docs_service.rs @@ -886,4 +886,64 @@ mod tests { s.update(&cx, "users", id, json!({ "x": 2 })).await.unwrap(); let _ = s.delete(&cx, "users", id).await.unwrap(); } + + /// `PICLOUD_DOCS_MAX_VALUE_BYTES` — the docs analogue of the KV anti-DoS rail. + /// Pins that an oversized document is rejected BEFORE authz, and covers create + /// AND update (both encode-and-check). The cx is an authenticated member with + /// no role (authz-denied) so the ordering is observable: size-first returns + /// `ValueTooLarge`, authz-first would return `Forbidden`. An anon cx couldn't + /// tell the two apart. + #[tokio::test] + async fn oversized_document_is_rejected_before_authz() { + let s = DocsServiceImpl::with_max_value_bytes( + Arc::new(InMemoryDocsRepo::default()), + Arc::new(DenyingAuthzRepo), + Arc::new(NoopEventEmitter), + 16, + ); + let cx = member_no_role_cx(AppId::new()); + + let err = s + .create(&cx, "users", json!({ "blob": "x".repeat(100) })) + .await + .unwrap_err(); + assert!( + matches!(err, DocsError::ValueTooLarge { limit: 16, .. }), + "an oversized create must be ValueTooLarge before authz; got {err:?}" + ); + + // Control: an under-cap create with the same cx is Forbidden — the cx is + // genuinely authz-denied, so the case above bypassed authz. And it seeds a + // doc via an ALLOWING service so we can then test the update path. + assert!( + matches!( + s.create(&cx, "users", json!({ "x": 1 })).await.unwrap_err(), + DocsError::Forbidden + ), + "the control confirms this cx is authz-denied" + ); + + // Update also encodes-and-checks before authz — an update that skipped the + // cap would be a free bypass. Seed with an allowing service, then update + // through the denied one. + let seeder = DocsServiceImpl::with_max_value_bytes( + Arc::new(InMemoryDocsRepo::default()), + Arc::new(AllowingAuthzRepo), + Arc::new(NoopEventEmitter), + 16, + ); + let owner = owner_cx(AppId::new()); + let id = seeder + .create(&owner, "users", json!({ "x": 1 })) + .await + .unwrap(); + let err = seeder + .update(&owner, "users", id, json!({ "blob": "x".repeat(100) })) + .await + .unwrap_err(); + assert!( + matches!(err, DocsError::ValueTooLarge { limit: 16, .. }), + "an oversized update must be ValueTooLarge too; got {err:?}" + ); + } } diff --git a/crates/manager-core/src/kv_service.rs b/crates/manager-core/src/kv_service.rs index 672a291..76a4cb3 100644 --- a/crates/manager-core/src/kv_service.rs +++ b/crates/manager-core/src/kv_service.rs @@ -561,6 +561,48 @@ mod tests { // No panic, no Forbidden. } + /// `PICLOUD_KV_MAX_VALUE_BYTES` is called out in CLAUDE.md as an anti-DoS rail: + /// oversized values are rejected "before authz so anonymous public scripts + /// can't DoS Postgres JSONB columns." Nothing asserted it. This does, and it + /// pins the ORDERING — the security-relevant part. + /// + /// The cx is an AUTHENTICATED member with no role, which `DenyingAuthzRepo` + /// rejects (see `authed_cx_with_no_role_is_forbidden`). That is what makes the + /// ordering observable: an anon cx passes `script_gate` regardless, so it + /// could not tell the two orders apart. With a denied cx, a size-check-first + /// service returns `ValueTooLarge` and an authz-first one returns `Forbidden`. + #[tokio::test] + async fn oversized_value_is_rejected_before_authz() { + let kv = KvServiceImpl::with_max_value_bytes( + Arc::new(InMemoryKvRepo::default()), + Arc::new(DenyingAuthzRepo), + Arc::new(NoopEventEmitter), + 16, + ); + let cx = member_no_role_cx(AppId::new()); + + let err = kv + .set(&cx, "widgets", "k", serde_json::json!("x".repeat(100))) + .await + .unwrap_err(); + assert!( + matches!(err, KvError::ValueTooLarge { limit: 16, .. }), + "an oversized value must be rejected as ValueTooLarge BEFORE authz; an \ + authz-first order would return Forbidden for this denied cx. got {err:?}" + ); + + // Control: an under-cap value with the SAME denied cx returns Forbidden — + // proving the cx really is denied, so the case above genuinely bypassed authz. + let err = kv + .set(&cx, "widgets", "k", serde_json::json!("x")) + .await + .unwrap_err(); + assert!( + matches!(err, KvError::Forbidden), + "the control confirms this cx is authz-denied; got {err:?}" + ); + } + /// Authenticated principal with no role on the app: the /// `DenyingAuthzRepo` returns no membership, so the capability /// check denies. Set must surface KvError::Forbidden. diff --git a/crates/manager-core/src/pubsub_service.rs b/crates/manager-core/src/pubsub_service.rs index 06d379f..8b27f79 100644 --- a/crates/manager-core/src/pubsub_service.rs +++ b/crates/manager-core/src/pubsub_service.rs @@ -775,4 +775,47 @@ mod tests { other => panic!("expected SubscriberToken, got {other:?}"), } } + + /// `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` — CLAUDE.md: "Prevents one publish from + /// amplifying into N outbox rows × M MB." Pins that an oversized message is + /// rejected BEFORE authz. The cx is an authenticated member that + /// `DenyingAuthzRepo` rejects, so the ordering is observable: size-first + /// returns `MessageTooLarge`, authz-first would return `Forbidden`. + #[tokio::test] + async fn oversized_message_is_rejected_before_authz() { + let repo = Arc::new(InMemoryPubsubRepo::new(vec![])); + let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo)).with_max_message_bytes(16); + let cx = member_cx(AppId::new()); + + let err = svc + .publish_durable( + &cx, + "events", + serde_json::json!({ "blob": "x".repeat(100) }), + ) + .await + .unwrap_err(); + assert!( + matches!(err, PubsubError::MessageTooLarge { limit: 16, .. }), + "an oversized publish must be MessageTooLarge before authz; an authz-first \ + order would return Forbidden for this denied cx. got {err:?}" + ); + // The oversized publish never reached the repo — no outbox amplification. + assert_eq!( + repo.written_count(), + 0, + "a rejected publish must not write any fan-out rows" + ); + + // Control: an under-cap publish with the same cx is Forbidden — confirming + // the cx is authz-denied, so the case above genuinely bypassed authz. + let err = svc + .publish_durable(&cx, "events", serde_json::json!({ "ok": 1 })) + .await + .unwrap_err(); + assert!( + matches!(err, PubsubError::Forbidden), + "the control confirms this cx is authz-denied; got {err:?}" + ); + } } diff --git a/crates/manager-core/src/queue_service.rs b/crates/manager-core/src/queue_service.rs index ee3b205..bf9b272 100644 --- a/crates/manager-core/src/queue_service.rs +++ b/crates/manager-core/src/queue_service.rs @@ -275,6 +275,30 @@ mod tests { } } + /// An AUTHENTICATED member with no app role. `DenyAllAuthz` denies it — unlike + /// an anon cx, which passes `script_gate` regardless, so only an authenticated- + /// denied cx can distinguish "size check ran before authz" from "after". + fn member_cx() -> SdkCallCx { + use picloud_shared::{InstanceRole, Principal, UserId}; + let exec = ExecutionId::new(); + SdkCallCx { + app_id: AppId::new(), + script_id: ScriptId::new(), + principal: Some(Principal { + user_id: UserId::new(), + instance_role: InstanceRole::Member, + scopes: None, + app_binding: None, + }), + execution_id: exec, + request_id: RequestId::new(), + trigger_depth: 0, + root_execution_id: exec, + is_dead_letter_handler: false, + event: None, + } + } + #[tokio::test] async fn empty_queue_name_rejects() { let repo = Arc::new(CapturingRepo { @@ -366,11 +390,15 @@ mod tests { #[tokio::test] async fn oversized_payload_is_rejected_before_authz() { + // DenyAllAuthz + an authenticated cx makes the ORDERING observable: a + // size-first service returns PayloadTooLarge, an authz-first one would + // return Forbidden. (The previous version used AlwaysAllow + anon, which + // caught a DROPPED cap but not a REORDERED one.) 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 svc = QueueServiceImpl::with_max_payload_bytes(repo, Arc::new(DenyAllAuthz), 16); + let cx = member_cx(); let payload = serde_json::json!({ "blob": "x".repeat(200) }); let err = svc .enqueue(&cx, "jobs", payload, EnqueueOpts::default()) @@ -378,7 +406,23 @@ mod tests { .unwrap_err(); assert!( matches!(err, QueueError::PayloadTooLarge { .. }), - "expected PayloadTooLarge, got {err:?}" + "an oversized payload must be PayloadTooLarge before authz; an authz-first \ + order would return Forbidden for this denied cx. got {err:?}" + ); + // Control: an under-cap payload with the same cx is Forbidden — confirming + // the cx is authz-denied, so the case above genuinely bypassed authz. + let err = svc + .enqueue( + &cx, + "jobs", + serde_json::json!({ "ok": 1 }), + EnqueueOpts::default(), + ) + .await + .unwrap_err(); + assert!( + matches!(err, QueueError::Forbidden), + "the control confirms this cx is authz-denied; got {err:?}" ); } @@ -396,30 +440,17 @@ mod tests { #[tokio::test] async fn authed_principal_denied_returns_forbidden_variant() { - use picloud_shared::{InstanceRole, Principal, UserId}; let repo = Arc::new(CapturingRepo { last: tokio::sync::Mutex::new(None), }); let svc = QueueServiceImpl::new(repo, Arc::new(DenyAllAuthz)); - let exec = ExecutionId::new(); - let cx = SdkCallCx { - app_id: AppId::new(), - script_id: ScriptId::new(), - principal: Some(Principal { - user_id: UserId::new(), - instance_role: InstanceRole::Member, - scopes: None, - app_binding: None, - }), - execution_id: exec, - request_id: RequestId::new(), - trigger_depth: 0, - root_execution_id: exec, - is_dead_letter_handler: false, - event: None, - }; let err = svc - .enqueue(&cx, "x", serde_json::Value::Null, EnqueueOpts::default()) + .enqueue( + &member_cx(), + "x", + serde_json::Value::Null, + EnqueueOpts::default(), + ) .await .unwrap_err(); assert!(matches!(err, QueueError::Forbidden));