feat(v1.1.4): outbound HTTP SDK + cron triggers

HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
  (manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
  `dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
  a literal-IP check at URL-parse time. Scheme/port restrictions, request
  + response body caps (stream-with-cap), layered timeout. Error reason is
  a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
  (logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
  brief's body-vs-opts contradiction; unknown opt keys throw). Body
  dispatch by type; response `#{status,headers,body,body_raw}` with JSON
  auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
  Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).

Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
  `cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
  schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
  outbox; the dispatcher delivers them (one-line match-arm extension).
  Catch-up fires exactly once per trigger per tick, not once per missed
  window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
  cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).

Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-03 20:23:18 +02:00
parent 6f17259e06
commit 10b5f655d5
39 changed files with 3828 additions and 53 deletions

View File

@@ -1,5 +1,92 @@
# PiCloud Changelog
## v1.1.4 — Outbound HTTP & Cron triggers (unreleased)
Two surfaces. **`http::*`** lets Rhai scripts make outbound HTTP
requests (Slack webhooks, Stripe, third-party REST) fronted by an SSRF
deny-list applied to the *resolved IP* (DNS-rebinding defense), with
scheme/port restrictions, request/response body caps, and a layered
timeout. **Cron triggers** add the fourth concrete kind on the v1.1.1
trigger framework: a scheduler task enqueues due triggers into the same
universal outbox the dispatcher already drains.
### Added
- **`http::{get,post,put,patch,delete,head,post_form,request}`** — outbound
HTTP SDK. Body and options are separate positional args
(`verb(url, body, opts)`); `opts` is
`{headers, timeout_ms, follow_redirects, max_redirects}` (unknown keys
throw). Body dispatch by type: Map/Array → JSON, String → text/plain,
`()` → none. Response is `#{ status, headers, body, body_raw }` with
`body` auto-parsed when the response is `application/json`. Non-2xx
does NOT throw (fetch-style); network/timeout/SSRF/size errors throw
with an `"http: …"` prefix.
- **SSRF deny-list** — applied to the resolved IP via a custom reqwest
`dns_resolver` (so it covers every redirect hop and defeats DNS
rebinding), plus a literal-IP check at URL-parse time. Blocks
loopback, RFC1918 private, link-local (incl. `169.254.169.254`),
carrier-grade NAT, multicast, reserved, IPv6 ULA/link-local/loopback,
and IPv4-mapped IPv6 (re-checked against the embedded v4 address).
The script-visible error carries a CIDR-category reason, never the IP.
`PICLOUD_HTTP_ALLOW_PRIVATE=true` disables it (dev-only; logs a startup
warning).
- **`HttpService` trait** (`picloud-shared`) + `HttpServiceImpl`
(manager-core, reqwest-backed). Wired into the `Services` bundle as
`http: Arc<dyn HttpService>`.
- **`Capability::AppHttpRequest(AppId)`** — maps to the existing
`script:write` scope (any outbound request can exfiltrate data, so the
conservative write mapping is used). No new `Scope` variant — the
seven-scope commitment holds. Script-as-gate: skipped when the script
runs unauthenticated.
- **Cron triggers** — `POST /api/v1/admin/apps/{id}/triggers/cron`
(`script_id`, `schedule`, `timezone`, optional retry overrides).
6-field cron expressions (with seconds) validated by the `cron` crate;
IANA timezones validated by `chrono-tz`. A scheduler task
(`spawn_cron_scheduler`, poll cadence `PICLOUD_CRON_TICK_INTERVAL_MS`,
default 30s) enqueues due triggers into the outbox; the existing
dispatcher delivers them. Catch-up policy: a trigger that missed N
windows fires exactly **once** on the next tick, not N times.
- **`ctx.event.cron`** — `{ schedule, timezone, scheduled_at, fired_at }`
for cron-trigger handlers (`ctx.event.source == "cron"`,
`ctx.event.op == "tick"`).
- **Dashboard Triggers tab** — admin-gated cron trigger create form
(target endpoint script, schedule, timezone dropdown) + triggers list
showing schedule / timezone / last-fired.
### Changed
- **Workspace version**: `1.1.3``1.1.4`.
- **Rhai SDK version**: `1.4``1.5` (additive — `http::*` SDK +
`ctx.event.cron`). The `Services` bundle constructor becomes
`Services::new(kv, docs, dead_letters, events, modules, http)`.
- **Dashboard version**: `0.9.0``0.10.0`.
- **`SdkCallCx`** — gains a `script_id` field (audit attribution + the
default outbound `User-Agent`, `picloud/<version> (script:<id>)`).
- **Rhai pin tightened** — workspace dep `rhai = "1.19"``rhai = "=1.24"`
so future bumps of the non-semver-stable `internals` surface are
deliberate.
- **Module backend errors redacted** — `PicloudModuleResolver` now
surfaces a stable generic (`"module backend unavailable; check server
logs"`) to scripts and logs the original at error level, instead of
leaking the backend error verbatim (see v1.1.3 follow-up).
### Migrations
- `0017_cron_triggers.sql` — widens `triggers.kind` and
`outbox.source_kind` CHECK constraints to include `'cron'`; adds
`cron_trigger_details (trigger_id, schedule, timezone, last_fired_at)`
with a `last_fired_at` index. Additive — applies cleanly on a fresh DB
and on top of the v1.1.3 schema.
### New environment variables
- `PICLOUD_HTTP_ALLOW_PRIVATE` (default false; dev-only) — disable the
SSRF deny-list.
- `PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES` / `PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES`
(default 10 MB each).
- `PICLOUD_CRON_TICK_INTERVAL_MS` (default 30000) — cron scheduler poll
cadence (floored at 1s).
## v1.1.3 — Modules (unreleased)
Real per-app Rhai module system. Scripts can `import "<name>" as
@@ -84,6 +171,21 @@ per-invocation compile cost; both invalidate on `updated_at` change.
- **Route creation** — `POST /api/v1/admin/scripts/{id}/routes`
returns 400 when the target script is `kind = 'module'`.
### Security fix
- **Cross-app trigger target (CVE-class: broken access control).** In
v1.1.1 and v1.1.2, `POST /api/v1/admin/apps/{id}/triggers/{kv,docs,dead_letter}`
validated only that the caller could manage triggers on `{id}` — it
did **not** verify that the target `script_id` belonged to that same
app. A member with trigger-management rights on app A could therefore
register a trigger in A pointing at a script owned by app B, causing
B's script to execute on A's events (a cross-app isolation break).
v1.1.3 closes this: every trigger-create handler now loads the target
script and rejects it unless `script.app_id == path app_id` (and it is
not a module). **Upgrade recommendation:** anyone running a pre-v1.1.3
multi-tenant deploy should upgrade and audit existing `triggers` rows
for any whose `script_id` resolves to a script in a different `app_id`.
### Migrations
- `0015_scripts_kind.sql` — adds `scripts.kind` with CHECK