fix(apps): deny preview-flow restart replay for raw app SDK tokens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz
This commit is contained in:
Diego Imbert
2026-07-28 12:55:11 +02:00
parent 7c6810bbcb
commit 71cf73e6ed
2 changed files with 18 additions and 11 deletions
+8 -4
View File
@@ -743,13 +743,16 @@ pub fn has_raw_app_sdk_sentinel(scopes: Option<&[String]>) -> bool {
/// Without this, a captured SDK token escalates to the viewer's full account:
/// these jobs run attacker-chosen code (or resolve and install attacker-chosen
/// packages on a worker) and their own ephemeral credential is unscoped and
/// permissioned as the viewer. `workflow_as_code` counts even though its body is
/// just args: it copies the named queued job's `raw_code` into a new job without
/// an ownership check, so a pre-staged preview job replays as the viewer.
/// permissioned as the viewer. The last two count even though their bodies carry
/// no code, because they re-run code a *named* job already holds:
/// `workflow_as_code` copies the named queued job's `raw_code` into a new job
/// with no ownership check, and restarting a completed preview flow re-runs its
/// stored `raw_flow`. Either replays a pre-staged preview as the viewer, and the
/// scope layer can't tell a preview restart from a deployed-flow one.
/// The SDK never calls these — its run helpers are all by path/hash.
fn is_request_supplied_code_route(suffix: &str) -> bool {
// Prefixes, so the `_async` variants are covered too.
const CODE_ROUTES: [&str; 9] = [
const CODE_ROUTES: [&str; 10] = [
"jobs/run/preview",
"jobs/run_inline/preview",
"jobs/run_wait_result/preview",
@@ -759,6 +762,7 @@ fn is_request_supplied_code_route(suffix: &str) -> bool {
"jobs/run/dependencies",
"jobs/run/flow_dependencies",
"jobs/run/workflow_as_code",
"jobs/restart/f",
];
CODE_ROUTES.iter().any(|p| suffix.starts_with(p))
}
+10 -7
View File
@@ -4918,12 +4918,13 @@ mod embed_token_tests {
.includes(&required));
}
/// The raw-app frontend SDK token is bounded by two properties: no `apps:*`
/// scope (so the Apps-domain mint endpoints are unreachable and a captured
/// token can't renew itself past its expiry), and the `raw_app_sdk` sentinel
/// (so `jobs:run` can't reach the request-supplied-code endpoints, which
/// would escalate a captured token to arbitrary execution as the viewer).
/// Lock both, plus the viewer-permissioned surface each curated scope grants.
/// The raw-app frontend SDK token is bounded by no `apps:*` scope (so the
/// Apps-domain mint endpoints are unreachable and a captured token can't renew
/// itself past its expiry) and by the `raw_app_sdk` sentinel (so `jobs:run`
/// can't reach the endpoints that run caller-supplied or caller-named code —
/// which would escalate a captured token to arbitrary execution as the viewer
/// — and `users:read` can't reach the workspace member directory).
/// Lock all of it, plus the viewer-permissioned surface each curated scope grants.
#[test]
fn frontend_sdk_scopes_reach_declared_domains_but_never_mint_routes() {
// Mirror mint_raw_app_sdk_token: declared scopes + the narrowing sentinel.
@@ -4981,11 +4982,13 @@ mod embed_token_tests {
("/api/w/test/jobs/run/dependencies_async", "POST"),
("/api/w/test/jobs/run/flow_dependencies", "POST"),
("/api/w/test/jobs/run/flow_dependencies_async", "POST"),
// Replays a named queued preview job's raw code as the caller.
// Replay a named job's already-stored code as the caller: the queued
// preview's raw_code, or a completed preview flow's raw_flow.
(
"/api/w/test/jobs/run/workflow_as_code/some-uuid/main",
"POST",
),
("/api/w/test/jobs/restart/f/some-uuid", "POST"),
// `users:read` is presented to the viewer as "read your identity", so
// the workspace member directory must stay out of reach.
("/api/w/test/users/list", "GET"),