From 436aac162a5918984e5827a09e8c8ebd36b74868 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 14 Sep 2026 23:02:59 +0200 Subject: [PATCH] fix: a workspace clone sanitizes a NUL-bearing draft instead of copying it unstripped Co-Authored-By: Claude Opus 5 (1M context) --- ...3e75defb21ad6f9bb69c3132541434bbd149c.json | 16 -------- ...9fdb239ef507e23003db03aff7d35b02c540b.json | 16 ++++++++ .../windmill-api-workspaces/src/workspaces.rs | 39 +++++++++++-------- 3 files changed, 39 insertions(+), 32 deletions(-) delete mode 100644 backend/.sqlx/query-36b614c205aa11dfecbbfaab4c33e75defb21ad6f9bb69c3132541434bbd149c.json create mode 100644 backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json diff --git a/backend/.sqlx/query-36b614c205aa11dfecbbfaab4c33e75defb21ad6f9bb69c3132541434bbd149c.json b/backend/.sqlx/query-36b614c205aa11dfecbbfaab4c33e75defb21ad6f9bb69c3132541434bbd149c.json deleted file mode 100644 index a673e22c7e..0000000000 --- a/backend/.sqlx/query-36b614c205aa11dfecbbfaab4c33e75defb21ad6f9bb69c3132541434bbd149c.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n CASE WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n THEN value\n ELSE to_json(\n CASE WHEN typ IN ('script', 'flow')\n THEN to_jsonb(value) - 'on_behalf_of'\n ELSE to_jsonb(value) END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ) END,\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "36b614c205aa11dfecbbfaab4c33e75defb21ad6f9bb69c3132541434bbd149c" -} diff --git a/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json b/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json new file mode 100644 index 0000000000..f218653d46 --- /dev/null +++ b/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b" +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index dc2ae4538c..d79e32823b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7318,25 +7318,32 @@ async fn clone_drafts( // A script hash is content-addressed and copied as-is, so a script draft's base // still names a version the clone has. `clone_flows` / `clone_apps` mint new // `flow_version` / `app_version` ids, so those drafts arrive with no base (their - // staleness falls back to the timestamps) rather than one naming a version of - // the source workspace. A pre-sanitizer NUL escape makes `to_jsonb` raise, so - // such a row is copied untouched. - "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base) + // staleness falls back to the timestamps) rather than one naming a version of the + // source workspace, and the lineage field goes with it so the next autosave cannot + // re-derive the source id. + // + // `clean` is `strip_json_nul`'s parity rule in SQL: a draft written before that + // sanitizer can carry a U+0000 escape, which `to_jsonb` rejects. Sanitizing on the + // way in keeps such a row from either aborting the clone or arriving with its + // principal unstripped. Escaped backslashes are parked on chr(1) first, so only an + // odd-parity backslash-u0000 (a real NUL) is removed. chr(92) spells the backslash + // so no escape sequence reaches this source file. + r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base) SELECT $2, path, typ, - CASE WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0 - THEN value - ELSE to_json( - CASE WHEN typ IN ('script', 'flow') - THEN to_jsonb(value) - 'on_behalf_of' - ELSE to_jsonb(value) END - - CASE WHEN typ = 'flow' THEN 'version_id' - WHEN typ IN ('app', 'raw_app') THEN 'parent_version' - ELSE '' END - ) END, + to_json( + CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END + - CASE WHEN typ = 'flow' THEN 'version_id' + WHEN typ IN ('app', 'raw_app') THEN 'parent_version' + ELSE '' END + ), created_at, email, CASE WHEN typ = 'script' THEN base END - FROM draft - WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + FROM ( + SELECT d.path, d.typ, d.created_at, d.email, d.base, + replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean + FROM draft d + WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL) + ) s"#, source_workspace_id, target_workspace_id, authed_email,