Files
windmill/backend/migrations/20260619113554_scrub_draft_value_nul.up.sql
Ruben Fiszel 924f9c7e8d fix(backend): strip NUL bytes from draft values on write (#9673)
draft.value is a json column (not jsonb), so a client could store a U+0000
escape in it. Any later text extraction (`->>` / `to_jsonb`) on such a value
raises 22P05 "unsupported Unicode escape sequence" — one poisoned draft 500'd
the whole GET /drafts/list, silently hiding the home-page "This workspace has N
drafts" banner (and breaking the global drafts page).

Prevent it at the source: sanitize the value in update_draft (the only path that
writes client-supplied draft content) so a NUL never reaches the column.
strip_json_nul does a single backslash-parity-aware byte pass that removes real
NUL escapes (values and keys alike) while leaving a legitimate escaped backslash
intact — O(n) with no serde_json::Value tree to allocate, important because the
slow path is also hit by any value legitimately containing the text after a
backslash (e.g. script source). The clean path is a single substring check.

A SQL migration scrubs rows written before this, gated to genuinely-poisoned
rows (a real NUL makes value::jsonb raise, distinguishing it from a legitimately
escaped backslash). With the data clean, no read-side query needs to change.

Tests: unit tests for the strip helper (escaped-backslash no-op, real+literal
collision, odd-backslash-run parity, nested keys/values) and an integration test
that POSTs a NUL-bearing draft and asserts it is stored and listed NUL-free
(fails without the strip).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:33:36 +00:00

31 lines
1.3 KiB
SQL

-- One-time cleanup of drafts whose `json` value carries a real U+0000 (NUL)
-- escape — storable only because `draft.value` is `json`, not `jsonb`. Such a
-- value makes any `->>`/`to_jsonb` extraction raise `22P05`, which 500'd
-- GET /drafts/list. New writes are sanitized in the application layer
-- (update_draft → strip_json_nul); this fixes rows written before that landed.
--
-- Only genuinely-poisoned rows are touched: a real NUL makes `value::jsonb`
-- raise, which distinguishes it from a legitimately escaped backslash sequence
-- (which `jsonb` accepts). The text replace handles the real-world shape — a NUL
-- inside a text field. A contrived value where stripping the escape leaves
-- invalid JSON is left as-is (and can no longer be created).
DO $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT id, value FROM draft WHERE position(E'\\u0000' in value::text) > 0
LOOP
BEGIN
PERFORM r.value::jsonb; -- not poisoned (legit escaped backslash): skip
EXCEPTION WHEN others THEN
BEGIN
UPDATE draft
SET value = replace(r.value::text, E'\\u0000', '')::json
WHERE id = r.id;
EXCEPTION WHEN others THEN
NULL; -- pathological shape; cannot strip in SQL, no longer creatable
END;
END;
END LOOP;
END $$;