fix(cms-poc): remediate findings surfaced by the CMS proof-of-concept

Additive fixes surfaced by building a full CMS on PiCloud
(examples/cms-poc/). One migration (0077_apps_cors.sql); no destructive
changes.

Security:
- Close the 502 info-leak on user routes: an uncaught script error (incl.
  a throw) leaked the app UUID + script fn names + source line/col. The
  user-route (inbox) path now shares scrub_runtime_detail with the
  execute-by-id path — raw detail is logged under a correlation id and the
  client sees only "script execution error (ref: <uuid>)".

Added:
- docs::find $contains operator (array membership via JSONB @>), per-app
  and group-shared — the inverse of $in.
- App-user role management from API/CLI: pic users add-role / rm-role,
  backed by the apps/{id}/users/{user_id}/roles endpoints (AppUsersAdmin).
- Per-app CORS: pic apps cors set, apps.cors_allowed_origins; the
  orchestrator echoes an allowed Origin and answers OPTIONS preflight.
- Non-JSON request bodies: form-urlencoded -> object, text/* -> string,
  other -> base64; ctx.request.content_type added. Malformed JSON still 400s.
- Binary responses via #{ body_base64 } with a script-set Content-Type.
- workflow::run_status(run_id) SDK (F-038): the starting script can poll a
  run — #{ status, output, error, steps } or () when no such run belongs to
  the app (app_id is the isolation boundary); same AppInvoke gate as start.

Fixed:
- pic apply "app not found" is now actionable (points at pic apps create).
- Interceptor- (F-021) and workflow-step-bound (F-037) scripts no longer
  warn "no route or trigger" at plan time — both count as reachability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 20:15:17 +02:00
parent dbdd398d90
commit 5682be366c
32 changed files with 1361 additions and 92 deletions

View File

@@ -14,6 +14,7 @@ use axum::{
routing::post,
Extension, Json, Router,
};
use base64::Engine as _;
use chrono::Utc;
use picloud_executor_core::{
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
@@ -177,10 +178,75 @@ where
}
#[allow(clippy::too_many_lines)]
/// Fallback entry for every user route. Wraps the actual dispatch with F-030
/// per-app CORS: resolve Host→app to know the policy, answer a CORS preflight,
/// then tag the dispatched response's `Access-Control-Allow-Origin`. CORS is
/// the ONE cross-cutting concern applied to every outcome (success, 404,
/// error), so it lives here at the single chokepoint rather than in each arm.
async fn user_route_handler<E, R>(
State(state): State<DataPlaneState<E, R>>,
Extension(principal): Extension<Option<Principal>>,
request: Request,
) -> Response
where
E: ExecutorClient + 'static,
R: ScriptResolver + 'static,
{
let method = request.method().as_str().to_ascii_uppercase();
let host = request
.headers()
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("")
.to_string();
let origin = request
.headers()
.get("origin")
.and_then(|h| h.to_str().ok())
.map(str::to_string);
// Resolve Host → app first so we know which CORS policy applies. No app
// claims this host → flat 404 (no policy to tag against).
let Some(app_id) = state.app_domains.resolve_app(&host) else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("no app claims host {host:?}")
})),
)
.into_response();
};
let allowed = state.app_domains.cors_for(app_id);
let allow_origin = pick_allowed_origin(origin.as_deref(), &allowed);
// A browser CORS preflight is `OPTIONS` carrying `Origin` +
// `Access-Control-Request-Method`. Answer it here — the real request's
// method/path need not have a registered OPTIONS route.
if method == "OPTIONS"
&& request
.headers()
.contains_key("access-control-request-method")
{
return preflight_response(allow_origin.as_deref(), request.headers());
}
let mut response = match dispatch_user_route(&state, app_id, request, principal).await {
Ok(r) => r,
Err(e) => e.into_response(),
};
add_cors_headers(response.headers_mut(), allow_origin.as_deref());
response
}
/// Two-phase dispatch (blueprint §11.5) minus the Host→app resolution the CORS
/// wrapper already did: run the matcher on the app's slice and execute. CORS
/// headers are added by the caller.
async fn dispatch_user_route<E, R>(
state: &DataPlaneState<E, R>,
app_id: AppId,
request: Request,
principal: Option<Principal>,
) -> Result<Response, ApiError>
where
E: ExecutorClient + 'static,
@@ -203,19 +269,6 @@ where
.to_string();
let headers = request.headers().clone();
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
// then run the existing matcher on that app's slice. No app claims
// this host → flat 404; the path doesn't get the chance to fire.
let Some(app_id) = state.app_domains.resolve_app(&host) else {
return Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("no app claims host {host:?}")
})),
)
.into_response());
};
let Some(matched) = state
.routes
.match_request_for_app(app_id, &host, &method, &path)
@@ -255,12 +308,7 @@ where
Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))),
};
let body_json: Json_ = if body_bytes.is_empty() {
Json_::Null
} else {
serde_json::from_slice(&body_bytes)
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
};
let body_json = body_value_from_bytes(&body_bytes, content_type_of(&headers))?;
let header_map: BTreeMap<String, String> = headers
.iter()
.filter_map(|(k, v)| {
@@ -275,7 +323,7 @@ where
match matched.matched.dispatch_mode {
DispatchMode::Async => {
handle_async_route(
&state,
state,
app_id,
matched.matched.route_id,
matched.matched.script_id,
@@ -294,7 +342,7 @@ where
}
DispatchMode::Sync => {
handle_sync_route(
&state,
state,
app_id,
matched.matched.route_id,
matched.matched.script_id,
@@ -490,9 +538,80 @@ where
Ok(response)
}
// ----------------------------------------------------------------------------
// F-030 per-app CORS
// ----------------------------------------------------------------------------
/// Decide which `Access-Control-Allow-Origin` value to emit for a request
/// `Origin` given an app's allow-list. Returns the string to echo, or `None`
/// when the origin isn't allowed (⇒ no CORS headers ⇒ the browser blocks).
fn pick_allowed_origin(origin: Option<&str>, allowed: &[String]) -> Option<String> {
// `["*"]` opts into any origin. The app-user auth model is bearer-token
// (no cookies), so a wildcard ACAO carries no CSRF/credentials risk.
if allowed.iter().any(|o| o == "*") {
return Some("*".to_string());
}
let origin = origin?;
allowed
.iter()
.find(|o| o.eq_ignore_ascii_case(origin))
.map(|_| origin.to_string())
}
/// Append CORS response headers when the origin is allowed. No-op otherwise, so
/// an app that hasn't opted in keeps the exact pre-CORS behaviour (no header).
fn add_cors_headers(headers: &mut HeaderMap, allow_origin: Option<&str>) {
let Some(origin) = allow_origin else {
return;
};
if let Ok(v) = HeaderValue::from_str(origin) {
headers.insert(HeaderName::from_static("access-control-allow-origin"), v);
// A concrete (non-`*`) ACAO varies by request Origin — mark it so a
// shared cache keys per-origin. Harmless for `*`.
headers.insert(
HeaderName::from_static("vary"),
HeaderValue::from_static("Origin"),
);
}
}
/// Answer a CORS preflight (`OPTIONS` + `Access-Control-Request-Method`): a 204
/// with the allow headers when the origin is permitted, else a bare 204 with no
/// CORS headers (the browser then blocks the real request).
fn preflight_response(allow_origin: Option<&str>, request_headers: &HeaderMap) -> Response {
let mut headers = HeaderMap::new();
if allow_origin.is_some() {
add_cors_headers(&mut headers, allow_origin);
headers.insert(
HeaderName::from_static("access-control-allow-methods"),
HeaderValue::from_static("GET, POST, PUT, PATCH, DELETE, OPTIONS"),
);
// Reflect the browser's requested headers, else a sensible default.
let allow_headers = request_headers
.get("access-control-request-headers")
.and_then(|v| v.to_str().ok())
.and_then(|s| HeaderValue::from_str(s).ok())
.unwrap_or_else(|| HeaderValue::from_static("content-type, authorization"));
headers.insert(
HeaderName::from_static("access-control-allow-headers"),
allow_headers,
);
headers.insert(
HeaderName::from_static("access-control-max-age"),
HeaderValue::from_static("600"),
);
}
(StatusCode::NO_CONTENT, headers).into_response()
}
fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> Response {
let status =
StatusCode::from_u16(summary.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
// F-026: a `body_base64` envelope returns raw bytes with the script's
// Content-Type, bypassing JSON encoding.
if let Some(b64) = &summary.body_base64 {
return binary_response(status, &summary.headers, b64);
}
let mut http_headers = HeaderMap::new();
for (k, v) in summary.headers {
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
@@ -505,6 +624,59 @@ fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> R
(status, http_headers, Json(summary.body)).into_response()
}
/// F-026: build an HTTP response whose body is the decoded `body_base64` bytes.
/// The script's `Content-Type` header wins; absent, defaults to
/// `application/octet-stream`. Invalid base64 is an author error → opaque 500.
fn binary_response(
status: StatusCode,
script_headers: &BTreeMap<String, String>,
body_base64: &str,
) -> Response {
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(body_base64.trim()) else {
let correlation_id = Uuid::new_v4();
tracing::warn!(%correlation_id, "script returned invalid body_base64");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!("script execution error (ref: {correlation_id})")
})),
)
.into_response();
};
// `Vec<u8>` defaults the Content-Type to application/octet-stream; the
// script's own headers (inserted below, overriding) replace it if set.
let mut resp = (status, bytes).into_response();
let headers = resp.headers_mut();
for (k, v) in script_headers {
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
headers.insert(name, value);
}
}
resp
}
/// Scrub a runtime / SDK error string before it reaches an anonymous
/// data-plane caller. The raw detail leaks internals (the app UUID, script
/// function names, source line/col, filesystem paths, SSRF-policy hints) and
/// reflects attacker-thrown strings (`throw "..."`) back into the response;
/// log the full text server-side under a correlation id and return only a
/// stable generic message + the id.
///
/// Shared by both anonymous data-plane paths — the execute-by-id path
/// (`ApiError::into_response`) and the user-route inbox path
/// (`failure_to_response`) — so the two can't drift again. (The user-route
/// path previously leaked the raw detail: the 2026-06-11 audit scrub landed
/// only on execute-by-id.)
fn scrub_runtime_detail(detail: &str) -> String {
let correlation_id = Uuid::new_v4();
tracing::warn!(
%correlation_id,
detail = %detail,
"script runtime error (detail scrubbed from response)"
);
format!("script execution error (ref: {correlation_id})")
}
/// Map `InboxFailureKind` onto the design-notes §3 status-code table.
fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response {
let status = match kind {
@@ -515,7 +687,15 @@ fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response {
InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE,
InboxFailureKind::Platform => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(serde_json::json!({ "error": message }));
// A Runtime failure carries the raw Rhai/SDK error text (app UUID, fn
// names, line/col, attacker-thrown strings) — scrub it exactly as the
// execute-by-id path does. Other kinds are platform-generated and safe.
let error_text = if matches!(kind, InboxFailureKind::Runtime) {
scrub_runtime_detail(message)
} else {
message.to_string()
};
let body = Json(serde_json::json!({ "error": error_text }));
if matches!(kind, InboxFailureKind::Overloaded) {
return (status, [(axum::http::header::RETRY_AFTER, "1")], body).into_response();
}
@@ -585,6 +765,59 @@ fn parse_query_string(s: &str) -> BTreeMap<String, String> {
out
}
/// F-026: interpret a request body per its `Content-Type`, surfaced to scripts
/// as `ctx.request.body`. JSON (or an unspecified type) parses as before — a
/// malformed JSON body still 400s. Non-JSON bodies are no longer rejected:
/// `application/x-www-form-urlencoded` → an object of string fields, `text/*` →
/// the raw string, any other type → a base64 string of the raw bytes. The
/// script disambiguates via `ctx.request.headers["content-type"]`. This keeps
/// the existing `body: Value` field (no execution-DTO change) while lifting the
/// former JSON-only restriction.
fn body_value_from_bytes(bytes: &[u8], content_type: Option<&str>) -> Result<Json_, ApiError> {
if bytes.is_empty() {
return Ok(Json_::Null);
}
let mime = content_type
.unwrap_or("")
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
// JSON (default when unspecified — preserves the historical contract).
if mime.is_empty() || mime == "application/json" || mime.ends_with("+json") {
return serde_json::from_slice(bytes)
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")));
}
// Form-urlencoded → an object of string fields (the classic HTML form POST).
if mime == "application/x-www-form-urlencoded" {
let s = std::str::from_utf8(bytes)
.map_err(|_| ApiError::BadRequest("form body is not valid UTF-8".into()))?;
let obj: serde_json::Map<String, Json_> = parse_query_string(s)
.into_iter()
.map(|(k, v)| (k, Json_::String(v)))
.collect();
return Ok(Json_::Object(obj));
}
// Text → the raw string.
if mime.starts_with("text/") {
let s = std::str::from_utf8(bytes)
.map_err(|_| ApiError::BadRequest("text body is not valid UTF-8".into()))?;
return Ok(Json_::String(s.to_string()));
}
// Anything else (binary) → base64 of the raw bytes.
Ok(Json_::String(
base64::engine::general_purpose::STANDARD.encode(bytes),
))
}
fn content_type_of(headers: &HeaderMap) -> Option<&str> {
headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
}
// ----------------------------------------------------------------------------
// Marshalling
// ----------------------------------------------------------------------------
@@ -604,12 +837,7 @@ fn build_exec_request(
}
}
let body_json: Json_ = if body.is_empty() {
Json_::Null
} else {
serde_json::from_slice(body)
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
};
let body_json = body_value_from_bytes(body, content_type_of(headers))?;
let execution_id = ExecutionId::new();
Ok(ExecRequest {
@@ -651,6 +879,11 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
let status =
StatusCode::from_u16(resp.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
// F-026: binary response via `body_base64` (same as the user-route path).
if let Some(b64) = &resp.body_base64 {
return binary_response(status, &resp.headers, b64);
}
let mut http_headers = HeaderMap::new();
for (k, v) in resp.headers {
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
@@ -741,22 +974,12 @@ impl IntoResponse for ApiError {
// pool-exhaustion timing, and panic/backtrace
// fragments. They also reflect attacker-thrown
// strings (`throw "..."`) back into operator-facing
// JSON. This handler only ever serves anonymous
// data-plane callers (execute-by-id + user routes;
// the authenticated script-test path lives in
// manager-core and keeps verbose errors), so log the
// full text server-side under a correlation id and
// return only a stable generic message + the id.
let correlation_id = Uuid::new_v4();
tracing::warn!(
%correlation_id,
detail = %detail,
"script runtime error (detail scrubbed from response)"
);
(
StatusCode::BAD_GATEWAY,
format!("script execution error (ref: {correlation_id})"),
)
// JSON. Both anonymous data-plane paths (execute-by-id
// here + user routes via `failure_to_response`) scrub
// through `scrub_runtime_detail`; the authenticated
// script-test path lives in manager-core and keeps
// verbose errors.
(StatusCode::BAD_GATEWAY, scrub_runtime_detail(detail))
}
ExecError::Overloaded { .. } => unreachable!("handled above"),
},
@@ -764,3 +987,205 @@ impl IntoResponse for ApiError {
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use super::*;
async fn body_string(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
// The user-route (inbox) path must scrub a Runtime failure: the raw Rhai
// error (app UUID, function names, source line/col, attacker-thrown text)
// must never reach the anonymous data-plane caller. Regression guard for
// the 502 info-leak — the 2026-06-11 audit scrub had only covered the
// execute-by-id path.
#[tokio::test]
async fn runtime_failure_is_scrubbed_on_the_inbox_path() {
let leaky = "#{\"statusCode\": 401} @ 'app:2cced4ba-e20b-44c9-9080-d698cc2d4cca' \
(line 43, position 18)\nin call to function 'require_user'";
let resp = failure_to_response(InboxFailureKind::Runtime, leaky);
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
let body = body_string(resp).await;
assert!(
!body.contains("app:2cced4ba"),
"app UUID leaked in body: {body}"
);
assert!(
!body.contains("require_user"),
"function name leaked in body: {body}"
);
assert!(!body.contains("line 43"), "source position leaked: {body}");
assert!(
body.contains("script execution error (ref: "),
"expected scrubbed generic message, got: {body}"
);
}
// Non-Runtime failures are platform-generated (not attacker-influenced)
// and stay descriptive so operators/clients see the real reason.
#[tokio::test]
async fn non_runtime_failures_pass_through() {
let resp = failure_to_response(InboxFailureKind::Timeout, "request timed out");
assert_eq!(resp.status(), StatusCode::GATEWAY_TIMEOUT);
let body = body_string(resp).await;
assert!(body.contains("request timed out"), "got: {body}");
}
#[test]
fn scrub_hides_detail_but_keeps_a_reference() {
let scrubbed = scrub_runtime_detail("secret-marker-98765 in call to 'admin_guard'");
assert!(!scrubbed.contains("secret-marker-98765"));
assert!(!scrubbed.contains("admin_guard"));
assert!(scrubbed.starts_with("script execution error (ref: "));
}
// --- F-030 CORS ---
#[test]
fn cors_origin_selection() {
let allow = vec!["http://localhost:5173".to_string()];
// Exact match echoes the origin.
assert_eq!(
pick_allowed_origin(Some("http://localhost:5173"), &allow).as_deref(),
Some("http://localhost:5173")
);
// Case-insensitive on the scheme/host.
assert_eq!(
pick_allowed_origin(Some("HTTP://LOCALHOST:5173"), &allow).as_deref(),
Some("HTTP://LOCALHOST:5173")
);
// Non-listed origin → not allowed.
assert_eq!(pick_allowed_origin(Some("https://evil.test"), &allow), None);
// No allow-list → CORS off.
assert_eq!(
pick_allowed_origin(Some("http://localhost:5173"), &[]),
None
);
// Wildcard allows any origin (and even a missing Origin echoes `*`).
let star = vec!["*".to_string()];
assert_eq!(
pick_allowed_origin(Some("https://anything.test"), &star).as_deref(),
Some("*")
);
}
#[test]
fn cors_headers_added_only_when_allowed() {
let mut h = HeaderMap::new();
add_cors_headers(&mut h, Some("http://localhost:5173"));
assert_eq!(
h.get("access-control-allow-origin").unwrap(),
"http://localhost:5173"
);
assert_eq!(h.get("vary").unwrap(), "Origin");
let mut none = HeaderMap::new();
add_cors_headers(&mut none, None);
assert!(none.get("access-control-allow-origin").is_none());
}
#[tokio::test]
async fn preflight_allows_permitted_origin() {
let mut req_headers = HeaderMap::new();
req_headers.insert(
"access-control-request-headers",
HeaderValue::from_static("content-type, authorization"),
);
let resp = preflight_response(Some("http://localhost:5173"), &req_headers);
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let h = resp.headers();
assert_eq!(
h.get("access-control-allow-origin").unwrap(),
"http://localhost:5173"
);
assert!(h.get("access-control-allow-methods").is_some());
assert_eq!(
h.get("access-control-allow-headers").unwrap(),
"content-type, authorization"
);
// A disallowed origin gets a bare 204 with no CORS headers.
let blocked = preflight_response(None, &HeaderMap::new());
assert_eq!(blocked.status(), StatusCode::NO_CONTENT);
assert!(blocked
.headers()
.get("access-control-allow-origin")
.is_none());
}
// --- F-026 non-JSON request bodies ---
#[test]
fn body_json_is_parsed() {
let v = body_value_from_bytes(br#"{"a":1}"#, Some("application/json")).unwrap();
assert_eq!(v, serde_json::json!({ "a": 1 }));
}
#[test]
fn body_unspecified_type_still_requires_json() {
// Back-compat: no Content-Type ⇒ JSON, and malformed JSON still 400s.
assert!(body_value_from_bytes(b"not json", None).is_err());
let empty = body_value_from_bytes(b"", None).unwrap();
assert_eq!(empty, Json_::Null);
}
#[test]
fn body_form_urlencoded_becomes_object() {
let v = body_value_from_bytes(
b"name=ada&lang=rust",
Some("application/x-www-form-urlencoded"),
)
.unwrap();
assert_eq!(v, serde_json::json!({ "name": "ada", "lang": "rust" }));
}
#[test]
fn body_text_becomes_string() {
let v = body_value_from_bytes(b"hello world", Some("text/plain; charset=utf-8")).unwrap();
assert_eq!(v, Json_::String("hello world".into()));
}
#[test]
fn body_binary_becomes_base64() {
// A tiny PNG-ish byte sequence (not valid UTF-8) → base64 string.
let raw = [0x89u8, 0x50, 0x4E, 0x47, 0x00, 0xFF];
let v = body_value_from_bytes(&raw, Some("application/octet-stream")).unwrap();
let expected = base64::engine::general_purpose::STANDARD.encode(raw);
assert_eq!(v, Json_::String(expected));
}
// --- F-026 binary responses ---
#[tokio::test]
async fn binary_response_returns_raw_bytes_with_content_type() {
let raw = [0x89u8, 0x50, 0x4E, 0x47];
let b64 = base64::engine::general_purpose::STANDARD.encode(raw);
let mut headers = BTreeMap::new();
headers.insert("content-type".to_string(), "image/png".to_string());
let resp = binary_response(StatusCode::OK, &headers, &b64);
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers().get("content-type").unwrap(), "image/png");
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
assert_eq!(bytes.as_ref(), raw);
}
#[tokio::test]
async fn binary_response_defaults_octet_stream_and_rejects_bad_base64() {
// No Content-Type set → octet-stream default.
let ok = binary_response(StatusCode::OK, &BTreeMap::new(), "aGk="); // "hi"
assert_eq!(
ok.headers().get("content-type").unwrap(),
"application/octet-stream"
);
// Invalid base64 → opaque 500 (author error), not a panic.
let bad = binary_response(StatusCode::OK, &BTreeMap::new(), "!!!not base64!!!");
assert_eq!(bad.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}