fix(manager-core): F-S-001 cap kv/docs/pubsub/queue payload sizes (default 256 KB)
Files (per-file cap), secrets (64 KB default), and email (25 MB default)
already enforce limits; kv::set, docs::create/update, pubsub::publish_durable
and queue::enqueue accepted any JSON value straight to a JSONB column with
no size validation. An anonymous public-HTTP script could fill disk via
queue::enqueue or amplify a single publish into N outbox rows × payload bytes.
Adds four new error variants:
- KvError::ValueTooLarge { limit, actual }
- DocsError::ValueTooLarge { limit, actual }
- PubsubError::MessageTooLarge { limit, actual }
- QueueError::PayloadTooLarge { limit, actual }
Each stateful service grows a `max_value_bytes` field with:
- Conservative 256 KB default (DEFAULT_KV_MAX_VALUE_BYTES etc.).
- New `with_max_*` constructor preserving the old `new()` signature.
- Env-knob reader (`*_max_*_from_env()`) — mirrors SecretsConfig::from_env.
Validation runs at the entry point BEFORE authz so an anonymous DoS doesn't
pay a membership lookup per attempt.
Wired via env knobs:
- PICLOUD_KV_MAX_VALUE_BYTES
- PICLOUD_DOCS_MAX_VALUE_BYTES
- PICLOUD_PUBSUB_MAX_MESSAGE_BYTES
- PICLOUD_QUEUE_MAX_PAYLOAD_BYTES
Documented in CLAUDE.md runtime config table.
New unit test in queue_service verifying the cap fires before authz.
AUDIT.md anchor: F-S-001. Depends on F-Q-004 (Backend variant).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,12 +144,17 @@ pub async fn build_app(
|
||||
trigger_repo.clone(),
|
||||
outbox_repo.clone(),
|
||||
));
|
||||
let kv: Arc<dyn KvService> =
|
||||
Arc::new(KvServiceImpl::new(kv_repo, authz.clone(), events.clone()));
|
||||
let docs: Arc<dyn DocsService> = Arc::new(DocsServiceImpl::new(
|
||||
let kv: Arc<dyn KvService> = 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<dyn DocsService> = 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<dyn DeadLetterService> = 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<dyn PubsubService> = 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<dyn picloud_manager_core::queue_repo::QueueRepo> = Arc::new(
|
||||
picloud_manager_core::queue_repo::PostgresQueueRepo::new(pool.clone()),
|
||||
);
|
||||
let queue: Arc<dyn picloud_shared::QueueService> =
|
||||
Arc::new(picloud_manager_core::queue_service::QueueServiceImpl::new(
|
||||
let queue: Arc<dyn picloud_shared::QueueService> = 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
|
||||
|
||||
Reference in New Issue
Block a user