fix: a workspace clone sanitizes a NUL-bearing draft instead of copying it unstripped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-14 23:02:59 +02:00
co-authored by Claude Opus 5
parent 73b16863c0
commit 436aac162a
3 changed files with 39 additions and 32 deletions
@@ -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"
}
@@ -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"
}
@@ -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,