Repository navigation
v0.7.2 feat(jobworkerdb): sanitize user provided job data PostgreSQL cannot store - #11
Merged
Merged
Conversation
…nd JSON PostgreSQL stores neither zero bytes nor invalid UTF-8 in a text column, and jsonb additionally rejects two escape sequences that are valid JSON: the zero byte escape and unpaired UTF-16 surrogates. Both are accepted by json.Valid and encoding/json emits the first for a zero byte inside a Go string, so nothing upstream refuses such a value and only the write fails. sanitizeString removes what a text column cannot hold. sanitizeJSON additionally strips the two rejected escape classes with a surrogate-pair-aware scanner that keeps complete pairs intact and decodes hex case-insensitively. It is allocation-free for already-storable payloads, which is every real job write. Removing bytes can join a backslash to the text behind it and form an escape that was not in the input, so stripping repeats — but a bounded number of times. Crafted malformed input can chain those seams so each round exposes one more, which made an unbounded loop quadratic in scanning and allocation on the write hot path (108ms for a 30KB payload, versus 151us bounded). Input still unstorable after the cap is malformed JSON that PostgreSQL rejects anyway.
Sanitizing is applied where values are written: the job origin and payload, the error message, error data and result, and the job bundle origin. Writes that previously failed on unstorable characters now succeed with those characters removed, so a job carrying user data is persisted instead of lost. The in-memory Job is never modified, so a value read back may differ from the one passed in. Job.Type and JobBundle.Type are deliberately left verbatim. Job.Type is the dispatch key a worker registers for, so rewriting it would store a job no worker could ever claim, which is worse than failing the write. Three cases fail loudly rather than being silently repaired. An origin with nothing storable left violates the column's non-empty constraint. A payload with nothing storable left is rejected instead of being stored as the empty object that notnull.JSON binds nil to, which would otherwise be dispatched to a worker as an all-zero payload struct. An error message that sanitizes to empty is replaced by a placeholder, because an empty error_msg reads back as SQL NULL through nullable.NonEmptyString and would make a failed job report success. Sanitizing applies to values written, never to a value used to look rows up. DeleteJobsFromOrigin and DeleteJobBundlesFromOrigin match their argument verbatim: sanitizing is not injective, so normalizing a destructive unbounded key could collapse one origin onto another and delete jobs the caller never named, after any authorization it performed on the raw string. A WARN names the id, field and byte count whenever sanitizing actually removes something, so a stored value differing from what a producer sent is traceable.
Releases v0.7.2.
…2 changelog The v0.7.2 entry was missing three changes made after it was first written: the bounded rather than until-stable escape stripping and what that means for input it cannot fully repair, the distinct placeholder texts for the two ways an error message can end up empty, and the allocation-free cost of the common write path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Jobs carrying user provided data that PostgreSQL cannot store are now persisted with the unstorable characters removed, instead of failing the write and losing the job.
The problem
PostgreSQL stores neither zero bytes nor invalid UTF-8 in a
textcolumn.jsonbadditionally rejects two escape sequences that are valid JSON:encoding/jsonemits for a zero byte inside a Go string — so a marshalled payload carries no raw zero byte at all, and byte-level stripping is a no-op on itJSON.stringifyemits for a truncated emojiBoth are accepted by
json.Valid, soNewJobaccepts such a payload and nothing upstream refuses it. Only the INSERT fails, and the job is lost. Verified against PostgreSQL 16 — the zero-byte escape givesunsupported Unicode escape sequence, and a lone high surrogate givesUnicode low surrogate must follow a high surrogate.What changed
Sanitizing is applied where values are written: job origin and payload, error message, error data and result, and the bundle origin. The in-memory
Jobis never modified, so a value read back may differ from the one passed in.Not sanitized:
Job.TypeandJobBundle.Type.Job.Typeis the dispatch key a worker registers for — rewriting it would store a job no worker could ever claim, which is worse than failing the write.Fails loudly rather than being silently repaired:
notnull.JSONbinds nil as the empty object, so it would otherwise be queued and dispatched to a worker as an all-zero payload structerror_msgreads back as SQL NULL throughnullable.NonEmptyStringand would make a failed job reportSucceeded() == trueLookups are never normalized.
DeleteJobsFromOriginandDeleteJobBundlesFromOriginmatch their argument verbatim. Sanitizing is not injective, so normalizing a destructive unbounded key could collapse one origin onto another and delete jobs the caller never named, after any authorization performed on the raw string. Deleting too little is recoverable; deleting someone else's jobs is not.Observability: a
WARNnames the id, field and byte count whenever sanitizing actually removes something, so a stored value differing from what a producer sent is traceable instead of invisible.Behaviour change
This moves from fail-closed to silently transformed. Sanitizing deletes rather than replaces, so it joins whatever surrounded a removed character:
"a"+ zero byte +"b"is stored as"ab".Validate values after sanitizing, not before — a check that must hold for what a worker acts on belongs in the worker, against what it read back. Sanitizing is also not injective, so two JSON object keys differing only in unstorable characters collapse into a duplicate key and jsonb keeps the last. The
jobworkerdbpackage docs carry the full list of consequences, including thatContextWithSynchronousJobsdoes not sanitize at all.Performance
Sanitizing is on the hot path of every job write, and payloads can be large.
The clean path scans in place and never copies.
An earlier revision stripped repeatedly until stable. Crafted malformed input can chain removal seams so each round exposes exactly one more, which made that quadratic: 108ms for a 30KB payload, extrapolating to minutes for 1MB, with a transaction held open when the write came through
AddJobBundle. Now bounded at two extra rounds: 151us for the same input. Input still unstorable after the cap is malformed JSON that PostgreSQL rejects anyway, so it fails the write instead of burning CPU.TestSanitizeJSONBoundedOnChainedRemovalsasserts the work stays linear.Tests
sanitize.gois at 100% statement coverage.TestPostgresRejectsUnstorableStringsis a negative control that pins what PostgreSQL actually refuses, so the sanitizing tests cannot pass vacuously — including the assertion that Go'sjson.Validaccepts inputs jsonb rejects.Covered: both escape classes, valid surrogate pairs kept intact, uppercase hex spelling, escaped-backslash parity, truncated escapes, escapes formed by byte removal, the allocation-free contract (pointer identity), per-type NULL binding (
notnullvsnullable), the WARN log's field/id/byte-count labels, and end-to-end DB assertions for each write path including a bundled job'snum_jobs_stoppedcounter.Review
Reviewed by
/review(5 specialists + red team),/simplify(4 agents), and adversarial passes from both Claude and Codex. Findings fixed along the way included:Succeeded() == truewhile SQL saw it as errored, andDeleteFinishedJobsnever reaped itReleases v0.7.2.