From e676eb9ba71315667f299045c8ca396a3adc9441 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 12 Jun 2026 18:23:58 +0200 Subject: [PATCH] fix(audit-2026-06-11/F-SE-H-04): scrub runtime error detail from public data-plane responses ExecError::Runtime strings (filesystem paths, "blocked by SSRF policy: link-local" cloud-metadata reconnaissance, pool-exhaustion timing, panic fragments, and attacker-thrown strings) were returned verbatim in the public /api/v1/execute + user-route 502 bodies. This handler only serves anonymous data-plane callers (the authenticated script-test path is in manager-core and keeps verbose errors), so log the full text server-side under a correlation id and return a stable generic message plus the id for operator grep. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/orchestrator-core/src/api.rs | 26 ++++++++++++++++++- crates/picloud/tests/api.rs | 39 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/orchestrator-core/src/api.rs b/crates/orchestrator-core/src/api.rs index f52cdd3..5ef7b6a 100644 --- a/crates/orchestrator-core/src/api.rs +++ b/crates/orchestrator-core/src/api.rs @@ -750,7 +750,31 @@ impl IntoResponse for ApiError { ExecError::OperationBudgetExceeded => { (StatusCode::INSUFFICIENT_STORAGE, e.to_string()) } - ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()), + ExecError::Runtime(detail) => { + // Audit 2026-06-11 (F-SE-H-04) — runtime / SDK error + // strings leak internal detail to the public data + // plane: filesystem paths, "blocked by SSRF policy: + // link-local" (confirms cloud-metadata reachability), + // 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})"), + ) + } ExecError::Overloaded { .. } => unreachable!("handled above"), }, }; diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index 3e1841d..852e380 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -1234,6 +1234,45 @@ async fn execution_errors_are_still_logged(pool: PgPool) { assert!(logs[0]["response_body"]["error"].is_string()); } +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn runtime_error_detail_is_scrubbed_from_public_response(pool: PgPool) { + // Audit 2026-06-11 (F-SE-H-04) — a runtime error's verbose detail + // (here a thrown marker string, but in the wild SDK/SSRF/internal + // paths) must not reach the public data-plane response body. The + // caller gets a stable message + a correlation id; the full text + // only goes to the server log. + let (s, app_id) = server_with_app(pool).await; + let created: Value = s + .post("/api/v1/admin/scripts") + .json(&with_app( + &app_id, + json!({ + "name": "leaky", + "source": r#"throw "SECRET_MARKER_98765""#, + }), + )) + .await + .json(); + let id = created["id"].as_str().unwrap(); + + let r = s + .post(&format!("/api/v1/execute/{id}")) + .json(&json!({})) + .await; + r.assert_status(axum::http::StatusCode::BAD_GATEWAY); + let body: Value = r.json(); + let err = body["error"].as_str().unwrap(); + assert!( + !err.contains("SECRET_MARKER_98765"), + "runtime detail leaked to public response: {err}" + ); + assert!( + err.contains("ref:"), + "scrubbed response should carry a correlation id, got: {err}" + ); +} + // ============================================================================ // v1.1.3 — Modules: scripts.kind, route + trigger rejection, end-to-end import // ============================================================================