23 KiB
Security audit — DoS, rate limiting & resource exhaustion
Audit date: 2026-06-11
Scope: PiCloud at main (post-v1.1.9, pre-v1.2). Single-node MVP.
Reviewer: Subagent 08 of 10 (parallel security audit).
Threat model: Solo-dev / Pi-class hardware. A single anonymous attacker
or one noisy authenticated tenant should not OOM the host, fill the disk,
or starve the executor for other tenants in minutes.
Counts by severity
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 5 |
| Medium | 7 |
| Low | 6 |
| Info | 3 |
Verified / no-finding
These were investigated and judged adequate for the threat model:
- Concurrent script execution cap.
ExecutionGateuses a singletokio::sync::Semaphorewithtry_acquire_owned()and translatesNoPermits/ClosedintoAcquireError::Overloaded { retry_after_secs: 1 }, whichIntoResponseforApiErrormaps to HTTP 503 withRetry-After: 1s(crates/orchestrator-core/src/api.rs:712-721). Default 32, env-tunable viaPICLOUD_MAX_CONCURRENT_EXECUTIONS. Both sync HTTP and the dispatcher share the same gate so the dispatcher arms back off correctly when sync HTTP is saturated. Note on fairness:try_acquireis non-blocking so "fairness" is irrelevant — there is no queue, callers get 503 immediately. - Postgres pool default size.
init_dbincrates/picloud/src/lib.rs:603-619now defaults toDEFAULT_DB_MAX_CONNECTIONS = 32and is env-tunable viaPICLOUD_DB_MAX_CONNECTIONS, matching the gate. (F-P-003 from the prior audit was remediated.) Acquire timeout 5s. - Per-message SDK service caps.
kv_service,docs_service,pubsub_service,queue_service,files_service,secrets_serviceall enforce a per-message JSON-encoded byte cap (256 KB default, except files at 100 MB) before authz.kv_service.rs:141,docs_service.rs:93,pubsub_service.rs:178,queue_service.rs:82. (F-S-001 from the prior audit was remediated.) - Outbound HTTP defaults (SDK http::request). 30s default timeout,
60s hard ceiling; 10s connect timeout; 10 MB request/response body cap;
5 redirect default, 10 ceiling. SSRF deny-list on by default. See
crates/manager-core/src/http_service.rs:41-53. - Email outbound rate limit.
EmailRateLimiter(token bucket + per-(app, recipient) burst + 200/day per-app cap) gatesusers::*email surfaces (crates/manager-core/src/users_service.rs:139-187). F-S-002 remediated. - Outbox claim shape. Dispatcher claims at most
CLAIM_BATCH = 8rows per tick (100 ms default) withFOR UPDATE SKIP LOCKED, gates every dispatch through the sameExecutionGate, and reschedules on permit refusal — bounded by design. Seecrates/manager-core/src/dispatcher.rs:69,193-216,482-491. - Outbox row deletion. Outbox rows are deleted on success/dead-letter
inline (
OutboxRepo::delete), so the table doesn't accumulate completed rows under normal load. Bloat risk surveyed below as a Medium (stuck rows).
High
H-1 — No Axum default body limit; the only inbound 503-path is the
data-plane user route — all admin/email/webhook handlers fall under Axum's
default 2 MB and the /api/v1/execute/{id} handler reads body: Bytes
with no explicit override.
- Severity: High
- Location:
crates/orchestrator-core/src/api.rs:106-123(execute_by_id,body: Bytes, noDefaultBodyLimit);crates/manager-core/src/email_inbound_api.rs:131-136(inbound webhook,body: Bytes);crates/picloud/src/lib.rs:587-593(router composition — noRequestBodyLimitLayer); admin JSON handlers (Json<…>extractors atauth_api.rs:74,admin_users_api.rs,users_admin_api.rs, etc.). - Summary: Axum's
Bytes/Jsonextractors default to 2 MB.Caddyhas norequest_body { max_size … }directive in eitherCaddyfileorCaddyfile.prod(just unrestrictedreverse_proxy), so any request up to 10 GB (Caddy's hard ceiling) can hit picloud. Only the data-plane user-route handler (user_route_handlerinapi.rs:219) explicitly bounds the body at 10 MiB; everything else rides Axum's default.- For
/email-inbound/{app_id}/{trigger_id}the body isByteswith no override — so the 2 MB ceiling is the only thing standing between an anonymous attacker and a 2 MB JSON-parse fork-bomb per request. With no IP rate limit, one client can sustain hundreds of MB/s of CPU-boundserde_json::from_sliceand HMAC verification work, saturating one execution worker indefinitely. This is the canonical "no rate limit on a public unauthenticated endpoint" finding — see also H-3. - For
POST /api/v1/execute/{id}Axum's 2 MB default applies, but the request lands at the executor regardless of script size, meaning a script withbody_limit_kb = 32still pays the serialization cost for a 2 MB body.
- For
- Fix shape:
- Add a global
axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)layer at the router root (already implicit, but should be explicit- lower the default to ~1 MB).
- Add per-route higher limits where intentional (email inbound is typically tens of KB; lower it to 1 MB explicitly).
- Caddy:
request_body { max_size 5MB }in both Caddyfiles.
- Add a global
H-2 — Login endpoint has no rate limit; Argon2id is the work multiplier
- Severity: High
- Location:
crates/manager-core/src/auth_api.rs:74-167. - Summary:
POST /api/v1/admin/auth/loginis reachable unauthenticated, and on every request it does a username lookup + Argon2id verify (m=19456 KiB, t=2 default Argon2id params) viaspawn_blocking. There is no per-IP, per-username, or process-wide cap on requests / failed attempts. Argon2 is ~50–150 ms per attempt on Pi-class hardware; an anonymous attacker with 32 in-flight HTTP requests can keep every blocking-thread worker pinned on Argon2 forever, and can attempt ≈600 password guesses/sec process-wide. Username enumeration is correctly defended (dummy-hash path) so the same Argon2 cost is paid on bad usernames — which means the cost inflicts on the defender for every miss. - Threat: Combined with H-1 (no body limit on
Json<LoginRequest>) this is the cheapest CPU-DoS in the system. A laptop attacker can fully saturate a Raspberry Pi's executor in seconds. - Fix shape: add a sliding-window login rate limiter keyed on
(client_ip, username)(e.g. 5 attempts / 60s, lockout for 5 min after 20), and a globalSemaphorecap (e.g. 4 concurrent in-flight Argon2 verifies) so admin auth can't starve the executor'sspawn_blockingpool. Memory only — Postgres-backed counters add a round-trip. The existingEmailRateLimiterpattern inusers_service.rs:139is the shape to copy.
H-3 — Email-inbound webhook has no IP rate limit; HMAC is optional
- Severity: High
- Location:
crates/manager-core/src/email_inbound_api.rs:131-191, mounted outsiderequire_authenticatedatcrates/picloud/src/lib.rs:571-575. - Summary:
POST /api/v1/email-inbound/{app_id}/{trigger_id}is public. When the trigger has aninbound_secretconfigured, HMAC verification is correct (timestamp-bound, ±5 min, in-memory replay dedup). But:- If the operator did NOT configure an
inbound_secretfor the trigger, no signature is required at all (see theif let (Some(ct), Some(nonce)) = …guard at line 150). An anonymous attacker who knows the trigger UUID can enqueue an unbounded number of outbox rows + executor invocations per second, bypassing every other rate limit. - With a secret configured, attackers still pay no cost for sending
garbage — the receiver must decrypt the secret from the secret
store (one DB call + master-key open) and HMAC-verify per request
before rejecting. No
(app_id, trigger_id)invalid-signature bucket. So an attacker who learns the URL but not the secret can still pin a worker thread on secret-decrypt + HMAC. - The body is
Byteswithout aDefaultBodyLimitoverride — falls under Axum's 2 MB cap (see H-1).
- If the operator did NOT configure an
- Threat: A single anonymous HTTP client knowing only an
app_id- a
trigger_id(often leaked through public docs / TLS cert transparency logs / mis-configured provider dashboards) can enqueue enough outbox rows to fill the dispatcher batch every tick. With retries, one row → up toretry_max_attempts(default 3) executions. Outbox grows unbounded; disk fills.
- a
- Fix shape: require HMAC at the trigger-row level (DB CHECK that
email triggers MUST have a secret); add per-
(app_id, trigger_id)token-bucket gating bothinbound_secret_missing(drop fast) andbad_signaturepaths; add aDefaultBodyLimit::max(1 MB)to the router merge inlib.rs.
H-4 — No per-app cap on trigger / route / cron registration
- Severity: High
- Location:
crates/manager-core/src/triggers_api.rs:127-140(create endpoints),crates/manager-core/src/route_admin.rs:160(create_route); no counterpartMAX_TRIGGERS_PER_APP/MAX_ROUTES_PER_APPconstant in the codebase. - Summary: An authenticated app developer can register an
unbounded number of triggers (KV, docs, files, pubsub, cron, email,
queue, dead-letter) and routes. Two amplifications follow:
- Cron storm.
cron_scheduler::tickSELECTs all enabled cron rows in one transaction (fetch_all, noLIMIT), iterates in Rust, and inserts one outbox row per due trigger (cron_scheduler.rs:117-167). 10 000 cron triggers all set to*/1 * * * * *(every second) → 10 000 outbox rows per second, each becoming a 100ms dispatcher claim of 8 rows + 32 concurrent executions. The dispatcher backs up; outbox bloats; Postgresnext_attempt_atindex degrades. - Pubsub fan-out.
pubsub_repo::fan_out_publishSELECTs all enabledpubsubtriggers for the app, filters in Rust ontopic_matches, inserts one outbox row per match in a single transaction (pubsub_repo.rs:81-115). N triggers subscribed to the same topic → N writes per publish. - KV / docs / files fan-out. Same pattern in
outbox_event_emitter.rs:88-103— one outbox row per matching trigger per mutation.
- Cron storm.
- Threat: A single tenant with
AppTriggerCreatecapability can register thousands of triggers and bring the whole node down by pubsub-publishing one message; or fill disk by cron-firing. Cross-tenant impact via shared dispatcher. - Fix shape: per-app trigger / route caps (e.g. 100 each, env-
tunable) enforced in the *_api create handlers; per-publish fan-out
cap (e.g. 100 matched triggers per publish, then dead-letter the
excess with a clear error); cron-scheduler tick LIMIT clause
(
LIMIT 1000) so a misbehaving app can't ride the whole tick budget.
H-5 — Cron-scheduler tick is unbounded fetch_all; one slow row blocks all
- Severity: High
- Location:
crates/manager-core/src/cron_scheduler.rs:115-173. - Summary: Every cron tick (default 30s) opens a transaction,
SELECTs every enabled cron row with
FOR UPDATE OF d SKIP LOCKED, computesnext_duein Rust, and INSERTs an outbox row + UPDATEslast_fired_atper due row — all serially inside one transaction. Three concrete failure modes:- Long-running tx. With N triggers and the per-row cost ~1ms,
a few thousand cron rows make the tick exceed the tick interval,
pile up overlapping transactions, and hold row-locks on
cron_trigger_detailsfor seconds — blocking dashboard reads of the same table. - No LIMIT. A malicious or buggy tenant registering 100 000 cron triggers turns every tick into a multi-second scan + INSERT-bomb.
- All-or-nothing commit. One serialization failure (very possible on Pg under load) rolls back every fire in the tick.
- Long-running tx. With N triggers and the per-row cost ~1ms,
a few thousand cron rows make the tick exceed the tick interval,
pile up overlapping transactions, and hold row-locks on
- Fix shape: add
LIMIT 1000to the cronfetch_all; break the per-tick work into smaller transactions; require a positivetick_interval_msfloor above 1s (already floored, but the per-row work is what's unbounded, not the tick).
Medium
M-1 — Outbox has no time-based retention sweep
- Severity: Medium
- Location:
crates/manager-core/src/outbox_repo.rs(noprune_expired/delete_older_than);crates/manager-core/src/gc.rs(sweeps dead_letters + abandoned + app-user tokens, but NOT outbox). - Summary: Outbox rows are deleted inline on success or when
exceeded; but stuck rows (
claimed_atset but never released after a process crash mid-dispatch, or rows whosetrigger_depth > max_trigger_depthslip past) accumulate. With no GC sweep, a bug-induced leak silently bloats the outbox table. On Pi-class Postgres this becomes apparent after weeks, not hours, so Medium. - Fix shape:
spawn_outbox_gcmirroring the dead_letter pattern, pruning rows withclaimed_at < NOW() - INTERVAL '1 hour'(orphaned claims) and rows past a 30-day retention.
M-2 — No SSE subscriber cap per IP / app
- Severity: Medium
- Location:
crates/orchestrator-core/src/realtime_api.rs:85, 94-159. - Summary:
GET /realtime/topics/{topic}is gated by topic-token auth, but once authorized the subscriber stays connected indefinitely (heartbeat keepalive). No process-wide / per-IP / per-app cap on active subscriptions. A leaked token plus N connections from one client lets the attacker pin N hyper tasks + Nbroadcast::Receiverslots, each holding atokio::sync::broadcastchannel reference. Per topic the broadcast channel bufferscapacityitems; with many topics that's many MB of resident state. - Fix shape: per-IP cap (e.g. 4 active SSE connections per remote address), process-wide cap (e.g. 1024), and per-app cap (e.g. 64). 503 on overflow.
M-3 — No global cap on in-flight HTTP connections / slowloris exposure
- Severity: Medium
- Location:
crates/picloud/src/main.rs:79-83(TcpListener::bindthenaxum::servewith default config). - Summary: No
set_keepalive, noset_nodelay, no read/write header timeout, no concurrent-connection cap. Hyper defaults are permissive — slowloris-style attackers can hold thousands of half-open TLS handshakes and read-byte-per-second connections. Caddy fronts in prod and mitigates somewhat, but the dev/single- node deployment has no defense. - Fix shape: wrap the
TcpListenerwith a per-connectiontower::limit::ConcurrencyLimit(e.g. 256 concurrent connections); settcp_keepalive+http2_keep_alive_intervalon the hyper builder; setheader_read_timeout = 10s. Also document a Caddyservers { timeouts { read_header 10s read 30s write 30s } }block in both Caddyfiles.
M-4 — No per-app KV / docs / files row-count or byte quota
- Severity: Medium
- Location: repo-side; no
app_quotastable or check in any*_repo::insert. - Summary: Per-row caps exist (256 KB / 100 MB) but per-app
totals are unbounded. One tenant can store millions of KV rows or
fill
PICLOUD_FILES_ROOTto disk-full. Defense in depth: the CLAUDE.md note ("per-app quotas deferred to v1.2") explicitly acknowledges this; downgrading the severity reflects "documented v1.2 work" but it is still a real DoS today on a shared instance. - Fix shape:
app_quotastable withbytes_usedcolumns per service, updated transactionally on insert/update, checked at service-entry. Or a soft cap (warn + log) for v1.1.x; hard quota for v1.2.
M-5 — Dispatcher retry storm: HTTP-async retries up to retry_max_attempts with no per-target circuit breaker
- Severity: Medium
- Location:
crates/manager-core/src/dispatcher.rs:885-905,trigger_config.rs:83. - Summary: Async HTTP outbox rows retry per
TriggerConfigdefaultretry_max_attempts = 3with exponential backoff. No circuit breaker per(app_id, script_id)or per outbound host — a script that always fails fires 3 invocations per event for as long as events arrive. With pubsub fan-out from H-4, one publish → N triggers × 3 retries =3Nexecutor invocations. - Fix shape: per-
(app_id, script_id)failure counter with a configurable circuit-break threshold (e.g. 50% failures over 60s → pause for 5 min, write adead_lettersrow).
M-6 — Queue depth is unbounded; per-app queue count is unbounded
- Severity: Medium
- Location:
crates/manager-core/src/queue_service.rs:67-118(enqueue),crates/manager-core/src/queue_repo.rs:264-322(depth usesCOUNT(*)). - Summary:
queue::enqueuehas a per-message 256 KB cap but no per-queue depth cap. A loop in a public-HTTP script can fill a queue with millions of messages × 256 KB = GB of JSONB; combined withqueue::depthbeing a full scan (F-P-006, still open per AUDIT.md remediation state), every depth call magnifies the attack. - Fix shape:
PICLOUD_QUEUE_MAX_DEPTH_PER_QUEUE(e.g. 10 000) +PICLOUD_QUEUE_MAX_QUEUES_PER_APP(e.g. 100).
M-7 — Trigger fan-out from KV/docs/files mutations writes outbox row per matching trigger inline (sync), with no per-event cap
- Severity: Medium
- Location:
crates/manager-core/src/outbox_event_emitter.rs:88-103, 142-160, 200-…(similar pattern). - Summary: Each
kv::setmatching N triggers inserts N outbox rows inside the calling request's transaction. No per-event cap on N. Combined with H-4 (unbounded trigger registration), a singlekv::setcan stall a request for seconds while writing thousands of rows. Pubsub fan-out (pubsub_repo.rs:81-117) is the same shape. - Fix shape: cap
matches.len()at e.g. 100; reject with aPolicyTooManyMatchesif exceeded so the developer notices.
Low
L-1 — Login attempts are not logged for forensics
- Severity: Low
- Location:
crates/manager-core/src/auth_api.rs:74-167. - Summary: Failed login attempts are swallowed by the
invalid_credentials()return path with no structured log line, no counter, noauth_auditrow. After H-2 is fixed, the rate-limit decisions will need observability hooks; preempt that. - Fix shape:
tracing::warn!(client_ip, username, "admin login failed")(with username before the lookup so it can't leak whether the user exists via what's logged).
L-2 — tracing::info! JSON output is unlimited and unrotated
- Severity: Low
- Location:
crates/picloud/src/main.rs:222-227. - Summary:
init_tracingwrites to stdout in JSON. Operators running outside docker (where journald handles rotation) won't have automatic rotation; logs eat the disk over weeks. Notracing_appenderrolling file appender wired in. - Fix shape: document the journald /
docker logs --max-sizeexpectation, or wiretracing_appender::rolling::dailybehind an env var so the no-supervisor case is also safe.
L-3 — attach_principal_if_present runs Argon2 verify on every API-key prefix candidate on every authed request
- Severity: Low (already flagged at High in F-P-002; downgraded here because pretty much an authed-user-only problem).
- Location:
crates/manager-core/src/auth_middleware.rs:192-229. - Summary: AUDIT.md flagged this; including here because the DoS angle is real: a hot user with N API keys serializes every admin request behind N Argon2 verifies. A noisy authenticated tenant can stall the admin plane.
L-4 — Pubsub mint_subscriber_token has no per-app rate limit
- Severity: Low
- Location:
crates/manager-core/src/pubsub_service.rs:240-…. - Summary: Token minting requires authz, but an authenticated caller can mint unbounded tokens — each is a tiny HMAC, so no storage DoS, but it makes the topic-secret rotation surface noisier. Worth a sliding-window cap.
L-5 — outbox.insert failures are not visible to the script caller in a non-200 way for fan-out paths
- Severity: Low (correctness-adjacent)
- Location:
outbox_event_emitter.rs:101-103etc. - Summary: If an
INSERT INTO outboxfails (Postgres full disk, deadlock), the SDK call returns the error to the script. Good. But the script is free to retry-in-a-loop — there's no script-side circuit breaker for "the outbox is unavailable". A script could busy-loop hitting outbox-insert failures indefinitely. - Fix shape: none beyond M-5's circuit breaker.
L-6 — inbox_router / future SMTP ingress will need its own caps
- Severity: Low / Info
- Location: not yet implemented.
- Summary: Heads-up for v1.2 — SMTP ingress and queue triggers with external receivers will need the same connection-cap + IP rate limit pattern as H-3.
Info
I-1 — pubsub::publish_durable realtime broadcast does tokio::spawn + handle.await
- Location:
crates/manager-core/src/pubsub_service.rs:222-236. - Note: the
tokio::spawn(...)immediately followed byhandle.awaitdefeats the comment ("Run on a child task so a panicking broadcaster becomes a warn log") — a panic in the spawned task surfaces as aJoinErrorin the awaiting frame and the publish succeeds (because the durable fan-out already committed). The catch is correct, but the comment is slightly misleading. Not a DoS finding.
I-2 — DEFAULT_TICK_INTERVAL = 100 ms is reasonable for Pi
- Location:
crates/manager-core/src/dispatcher.rs:101. - Note: 100ms tick × 8 rows/tick = 80 dispatches/sec ceiling per
process, before the gate. Adequate for a single-node MVP. The
env-override (
PICLOUD_DISPATCHER_TICK_INTERVAL_MS) lets operators trade latency for CPU.
I-3 — Caddy in dev runs at :80 with auto_https off
- Location:
caddy/Caddyfile:24-26. - Note: Prod Caddyfile enables auto-HTTPS via ACME. No HTTPS for the dev port is fine for development; mentioned only because every rate-limit story benefits from terminating TLS at Caddy.
Recommendations, prioritized
- Login rate limit + global Argon2 semaphore (H-2). Highest leverage: closes the cheapest-CPU-DoS in the system.
- Require HMAC on email-inbound + per-IP bucket (H-3). One of two anonymous-reachable endpoints; the other is the script data plane, which already has a 503-gate.
- Per-app caps on triggers / routes / cron, plus cron tick LIMIT (H-4 + H-5). Closes the cross-tenant amplification path. Quotas land naturally in the v1.2 work the blueprint already plans.
- Explicit
DefaultBodyLimitat the router root (H-1). One-line fix; deep value. - Caddy
request_body { max_size … }andservers { timeouts …}in both Caddyfiles. (Closes H-1 / M-3 at the proxy.) - Outbox GC + per-app KV/docs row caps (M-1 / M-4). Mechanically easy; defends the long tail.
Methodology
Read AUDIT.md to map prior findings and remediation state, then read:
crates/orchestrator-core/src/gate.rs, client.rs, api.rs,
realtime.rs, realtime_api.rs; crates/manager-core/src/dispatcher.rs,
cron_scheduler.rs, auth_api.rs, email_inbound_api.rs,
pubsub_service.rs, pubsub_repo.rs, queue_service.rs,
kv_service.rs, docs_service.rs, files_service.rs, http_service.rs,
outbox_event_emitter.rs, outbox_repo.rs, gc.rs,
triggers_api.rs, route_admin.rs, trigger_config.rs,
users_service.rs (rate-limiter shape); crates/picloud/src/main.rs,
lib.rs; both Caddyfiles. Grepped for DefaultBodyLimit,
RequestBodyLimit, rate_limit, Semaphore, tracing_appender,
set_keepalive, count_for_app, MAX_TRIGGERS, MAX_ROUTES.