Turn three client inputs that raised into 422 violations - #61
Merged
Merged
Conversation
An array's validate: ran over the nils of elements that failed to cast, :integer raised FloatDomainError on a NaN/Infinity Float, and a String with invalid bytes crashed normalize: and format:. Each was a 500 where the request deserved a 422, and each broke Contract#call's promise never to raise. validate: now follows transform:'s rule and sees only a fully-valid array. A non-finite Float is invalid_type for :integer, as it already was for :float and :decimal. A malformed String is invalid_type, checked in Coercion.cast, the one door every scalar value passes through; apply_normalize steps aside for it so neither a preset nor an app's own proc is handed it. No new error code, so the exported code enumeration is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md # spec/contract_spec.rb
A valid String in another encoding still broke the cast: Integer() on UTF-16 "12" raised Encoding::CompatibilityError, :decimal read it byte by byte as 1, and binary or UTF-16 text raised in format: and normalize:. Every String is now read as UTF-8 before any cast (binary and US-ASCII read as-is on a copy, anything else via encode), and one that cannot be is invalid_type. normalize: reads the same text. An undeclared key with invalid UTF-8 was copied raw into a violation's param, so rendering the 422 raised JSON::GeneratorError, and a UTF-16 key raised while its path was built. permittable_path now makes every key reportable UTF-8, replacing what cannot be read with U+FFFD. The CHANGELOG now states the array validate: trade-off plainly, and the README says exactly what Contract#call still raises. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
Author
|
Review addressed in 25416d2 (merged master first; fast-forward push):
663 examples, 0 failures; RuboCop clean. PR body updated. 🤖 Generated with Claude Code |
Reading every String as UTF-8 broke controllers that use Rails' skip_parameter_encoding / param_encoding: binary Latin-1 became a 422 and Shift_JIS came back as UTF-8. A :string is now handed back exactly as it arrived. Only a String invalid in its own encoding is refused; number, boolean and date casts parse a UTF-8 copy of the text. A format: pattern that cannot be applied to a String's encoding is a format violation, and a normalizer that cannot handle it leaves the value as it is. Also: an array's transform: no longer runs after its validate: failed; an undeclared key inside an element no longer stops the array's validate: (told apart by identity, since a sub-field may return :unknown); and only undeclared keys are converted for reporting, not every declared key on every request. Docs now match the behaviour, including Contract#call(nil). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
Author
|
Regression pass addressed in 3ae9495 (fast-forward push):
671 examples, 0 failures; RuboCop clean. PR body updated. 🤖 Generated with Claude Code |
apply_normalize's rescue for a preset that cannot handle an encoding was written as a method-level rescue, so it also caught an early `return normalizer.call(value)` for an app-supplied Proc on non-UTF-8 input -- turning a business-rule ArgumentError into a silent pass. The leniency is now scoped to the preset call alone, identified by object identity against NORMALIZERS' values; a custom Proc's raise always propagates, on any encoding, exactly as on master. Also: Coercion.cast no longer re-scans a UTF-8 String's encoding a second time inside utf8_text when the fast path returns the same object -- ordinary UTF-8 input already had its validity checked by the line above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
Owner
Author
|
Final regression pass addressed in f7ccbb4 (merged master; fast-forward push):
693 examples, 0 failures; RuboCop clean. 🤖 Generated with Claude Code |
CHANGELOG only: keep master's entries ahead of this PR's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VSN2015
pushed a commit
that referenced
this pull request
Sep 25, 2026
CHANGELOG conflict: kept master's entries ahead of this PR's. Integration bug found while merging: #61's Coercion.reportable_text (scrub an undeclared key to U+FFFD for JSON safety) and #63's richer per-byte prose escaping (\xNN for an unmappable byte, char-by-char transcoding) both touched the same undeclared-key path, and #61 ran first — so by the time #63's escaping saw the key, its original bytes were already gone, replaced by U+FFFD. Two of #63's own specs caught this on merge (the legacy-key transcoding spec and the invalid-UTF-8 spec), and a third, pre-existing #61 spec asserted the now-stale scrubbed-U+FFFD text in the :log line. Fixed by separating the two purposes cleanly: - `param:` in `details`/instrumentation/problem+json `errors` stays Coercion.reportable_text — valid UTF-8, safe for to_json, unchanged. - Prose (the :log line and InvalidParameters#message alike) now reads the RAW key, converted only with permittable_prose_utf8 (encoding-tag-safe, byte-preserving — the same conversion #63 already uses), so #63's escaper can do its own richer per-byte transcoding. permittable_unknown_key_violation stashes this raw-based prose text in a side table, by identity, alongside the existing violation-identity tracking; permittable_violation_summary prefers it over v[:param] when present. Updated the one stale #61 spec to assert the new, more informative output (caf\xC3 instead of a bare U+FFFD) rather than weakening the fix, and added a new spec pinning the split: the exception message shows the rich transcoding, details/problem+json stay the plainer JSON-safe form for the same violation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VSN2015
added a commit
that referenced
this pull request
Sep 25, 2026
* Escape control characters in client-sent names in prose
The unknown: :log warn line, the monitor-mode warn line and
InvalidParameters#message interpolated request key names raw, so a key
containing a newline wrote a second, forged log entry. A name containing
a control character (\p{Cc}) or U+2028/U+2029 is now printed quoted and
escaped; ordinary names print byte-for-byte as before. Truncation cuts
between whole escapes. details and the instrumentation payload still
carry every name as sent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Close the remaining ways a name could fake or break the prose
Review of #63. A name is now quoted when it contains a quote anywhere
or the list separator ", ", not only a leading quote, so it cannot
pass for two names or fake the "and N more" count. Bidi embedding,
override and isolate characters join PROSE_UNSAFE. Only the
client-sent name is escaped; the developer's message is passed as a
separate suffix and printed as is. A name is judged by the part the
truncation shows, and only a bounded prefix of it is read. Every item
is emitted as UTF-8, so legacy-encoded bytes are not written raw and
names of mixed encodings join.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Close the lookalike, truncation and legacy-encoding forging paths
Regression pass on #63. A name that begins "and N more" is quoted, as
is one holding a quote lookalike (fullwidth quote, Pi/Pf) or a
fullwidth, small or ideographic comma. PROSE_UNSAFE is now the
property set Cc, Cf, Zl, Zp and every Zs but U+0020, so zero-width and
format characters and no-break spaces are escaped. A quoted name that
fits the limit is no longer cut to make room for its suffix. A
legacy-encoded key is transcoded per character, so only unmappable
bytes print as \xNN.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Close case and CJK-bracket gaps in the ambiguous-prose guard
Regression pass on #63. The overflow-phrase check ("and N more") is
now case-insensitive, since "And"/"AND" reads identically once
rendered. The quote-lookalike check now uses Unicode's own
Quotation_Mark property instead of enumerating Pi/Pf: it also covers
the CJK corner brackets U+300C/U+300D, real quotation marks in
Japanese/Chinese text that fall in Ps/Pe rather than Pi/Pf. U+FE51,
the small-form ideographic comma, joins the comma set. CHANGELOG notes
plainly that this guard defeats case and punctuation lookalikes, not
Unicode homoglyph substitution.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Merge master into the prose-escaping fix, and reconcile it with #61
CHANGELOG conflict: kept master's entries ahead of this PR's.
Integration bug found while merging: #61's Coercion.reportable_text (scrub
an undeclared key to U+FFFD for JSON safety) and #63's richer per-byte
prose escaping (\xNN for an unmappable byte, char-by-char transcoding) both
touched the same undeclared-key path, and #61 ran first — so by the time
#63's escaping saw the key, its original bytes were already gone, replaced
by U+FFFD. Two of #63's own specs caught this on merge (the legacy-key
transcoding spec and the invalid-UTF-8 spec), and a third, pre-existing
#61 spec asserted the now-stale scrubbed-U+FFFD text in the :log line.
Fixed by separating the two purposes cleanly:
- `param:` in `details`/instrumentation/problem+json `errors` stays
Coercion.reportable_text — valid UTF-8, safe for to_json, unchanged.
- Prose (the :log line and InvalidParameters#message alike) now reads the
RAW key, converted only with permittable_prose_utf8 (encoding-tag-safe,
byte-preserving — the same conversion #63 already uses), so #63's escaper
can do its own richer per-byte transcoding. permittable_unknown_key_violation
stashes this raw-based prose text in a side table, by identity, alongside
the existing violation-identity tracking; permittable_violation_summary
prefers it over v[:param] when present.
Updated the one stale #61 spec to assert the new, more informative output
(caf\xC3 instead of a bare U+FFFD) rather than weakening the fix, and added
a new spec pinning the split: the exception message shows the rich
transcoding, details/problem+json stay the plainer JSON-safe form for the
same violation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Sang <sang@Soangs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
VSN2015
pushed a commit
that referenced
this pull request
Sep 25, 2026
Master had moved to include #59-#61, #64 and #66 since this branch's last merge, plus #60 and #65 which both touch json_schema.rb's :decimal/:datetime export and lib/permittable/rspec.rb's `in:`/`default:` matcher chains — real conflicts, not just additive. lib/permittable.rb: kept this branch's permittable_transform seam (AuthoredValues needs it to walk a default without running transform: on it) — same guard (`violations.length == before`) as master's inline form. lib/permittable/json_schema.rb (4 hunks): combined rather than picked a side. - apply_in!'s enum export now uses BOTH #65's `field[:in_published] || allowed` (keeps a :date/:datetime member's authored String form) AND this branch's `decimal: :number` (keeps a :decimal member numeric and consistent with its own default/example export). - kept #60's `apply_normalize!` method (unrelated to this branch) ahead of json_value, whose signature this branch changes to `decimal: :string`. - json_value's Hash/BigDecimal/Time cases: kept this branch's `decimal:` threading for Hash/BigDecimal, and #65's `exact_iso8601` for Time (fixes sub-second Time :in members exporting as whole seconds — this branch's plain `.utc.iso8601` would have regressed that). - kept both decimal_json (this branch) and exact_iso8601 (#65) methods; each is used from the merged json_value body above. Verified with a probe: a :decimal in: list now exports numerically AND agrees with its own numeric default, and a :datetime in: list with sub-second members still exports the authored string via in_published — both at once, which neither branch alone tested. lib/permittable/rspec.rb (2 hunks) and spec/matchers_spec.rb: purely additive — #65's `cast_in`/`same_in?`/`within` alongside this branch's `default_mismatch`/`cast_default`. Combined by keeping both. README.md: combined the `default:` row (this branch, the transform: exception) with the `validate:` row (master, the array-skip-on-failed- validate note) — same table, different rows each PR had touched. Fixed along the way (found while merging, not part of either PR): - spec/permittable_spec.rb: a #61 perf spec asserted `valid_encoding?` is called on the caller's OWN string object, but this branch's permittable_own copies a request's String before Coercion.cast ever sees it (to avoid aliasing params) — so the original object is never touched, though the copy is (correctly) scanned exactly once. Rewrote the spec to count via a shared counter that survives `dup`, so it asserts the real invariant (one scan overall) rather than one specific object's identity. - spec/matchers_spec.rb: my own conflict resolution (both PRs inserted a new `it` block at the same point in the file) left one block's closing `end` missing — a syntax error that silently dropped all 42 examples in this file from the suite total without failing the run. Fixed; the suite total is now 819 (was silently 777). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
The bugs
A standalone
Contract#callon a webhook payload has nothing in front of it. A controller usingskip_parameter_encoding/param_encodingreceives binary or other-encoding Strings on purpose. JSON parsed withallow_nanreaches the NaN case.The fix
validate:runs only when no element violated. An undeclared key inside an element does not count.transform:runs only when nothing violated,validate:included, as on master and on the scalar path.:integerreturnsinvalid_typefor a non-finite Float, as:floatand:decimalalready did.invalid_typefor every scalar type.invalid_type.:stringis returned in its original encoding, bytes unchanged.format:cannot be applied to a String's encoding, that is aformatviolation.normalize:preset cannot handle the encoding, the value is left as it is. An app's ownnormalize:Proc always runs and always raises its own errors, on any encoding — this round's fix; see below.No new error codes, so the
ERROR_SCHEMAenumeration is unchanged.Where the checks live
Coercion.castis the one step every scalar value goes through: a field, anof:element, a sub-field of an array of hashes, and an authoreddefault:/example:. It refuses a String that failsvalid_encoding?, returns a:stringunchanged, and gives the other types autf8_textcopy — reusing the encoding check already done a line above when the value is already UTF-8, rather than scanning it twice (see Regression pass 3 below).Coercion.format_match?turns anEncodingError/ArgumentErrorfrom the regexp into "does not match".apply_normalizeskips a String that failsvalid_encoding?, so the cast refuses it. For a String that passes that check, only a built-in preset (identified by object identity againstNORMALIZERS' values) gets the encoding-failure leniency; an app-supplied Proc's raise always propagates.permittable_unknown_key_violationis the one place a client key enters a path, and the only placereportable_textruns. Declared keys are not converted, and a spec asserts that a clean nested request callsreportable_textzero times. The entry is also remembered by identity. That is howpermittable_check_arraytells an undeclared key from a failed sub-field, because the code alone cannot: a sub-field'svalidate:may return:unknown, and a spec covers that case.Regression pass 3 (final)
Two issues found on re-review of round 2's diff, both fixed here:
normalize:Proc's own raise was silently swallowed on non-UTF-8 input.apply_normalize's leniency for an encoding a preset can't handle was written as a method-levelrescue EncodingError, ArgumentError, so it also caught an earlyreturn normalizer.call(value)for an app-supplied Proc —normalize: ->(v) { raise ArgumentError, "business rule violated" if v.include?("FAKE") }propagated on master and on UTF-8 input, but silently returned the unvalidated value for Shift_JIS, Windows-1252 or binary-with-high-byte input. That madenormalize:an unintended bypass channel for exactly the apps this PR is meant to help (skip_parameter_encoding). Fixed by scoping the rescue to the preset call alone, gated onNORMALIZERS.value?(normalizer)— object identity, sinceresolve_normalizer!replacesfield[:normalize]with the exact Proc from that frozen Hash. A custom Proc'sArgumentErrororEncodingErrornow always propagates, on any encoding, exactly as on master. New spec: a custom Proc raising both error classes on Shift_JIS, Windows-1252 and binary input.:stringscalar field.Coercion.castcalledvalue.valid_encoding?directly, thenutf8_textre-checkedtext.valid_encoding?on the same object when its fast path returned ordinary UTF-8 input unchanged.castnow skips straight to the object it already validated when the encoding is UTF-8, and only callsutf8_textfor the encodings where it produces a genuinely new object (US-ASCII, binary, anything needingencode). New spec spies onvalid_encoding?via a String subclass and asserts exactly one call per cast.Behaviour changes worth reviewing
validate:trade-off. An array-level violation is no longer reported alongside element violations.[1, "x", 1]against a uniqueness validator reports onlyids[1], and the duplicate shows up after the client fixes that and resends. The CHANGELOG states this plainly, and a spec pins it.skip_parameter_encodingapps behave as on master, except that former crashes (format:, a built-in preset like:squish) are now violations, and a preset that can't handle the encoding now leaves the value as it is instead of raising. An app's ownnormalize:Proc is unaffected either way — see Regression pass 3.Contract#callstill raises:ArgumentErrorfor input that is not a Hash,nil(read as{}) orto_unsafe_h-able, plus the app's ownvalidate:/transform:/normalize:/finalizecode. It no longer claims that the app receives UTF-8.:json: deliberately not examinedA
:jsonfield's nested strings are neither checked nor converted, and a spec pins that down. The gem runs no string operation inside a:jsonvalue, so nothing there can raise in the gem. Walking every leaf on every request would add the cost the opaque type exists to avoid.Also
"name"key does not match a declared:name.origin/masteris merged into the branch, twice across the review rounds. CHANGELOG conflicts are resolved with master's entries first.Verification
:jsonpass-through, thevalidate:trade-off, the:unknown-returning sub-field, and an app normalizer bug on ordinary UTF-8 still raising. TheContract#callspecs checknot_to raise_errorand the exact violation, and callto_jsonon a violation for a non-UTF-8 key.🤖 Generated with Claude Code