Skip to content

Turn three client inputs that raised into 422 violations - #61

Merged
VSN2015 merged 7 commits into
masterfrom
fix/no-500-on-bad-input
Sep 25, 2026
Merged

VSN2015 merged 7 commits into
masterfrom
fix/no-500-on-bad-input

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

A bug fix. Client input raised out of the contract, which turned a 422 into a 500. Each case below is reproduced and now produces an ordinary violation. Revised after three review rounds. The encoding handling is inspect, never convert, so controllers using skip_parameter_encoding keep their bytes.

The bugs

  array :ids, of: :integer, validate: ->(a) { a.sum < 100 }
    sent ["x", 2]                     TypeError: nil can't be coerced into Integer
  required :n, :integer
    sent Float::NAN                   FloatDomainError: NaN
    sent "12".encode("UTF-16LE")      Encoding::CompatibilityError
  required :d, :decimal
    sent "12".encode("UTF-16LE")      accepted as 1   (read byte by byte)
  required :s, :string, normalize: :squish    (or format:, :strip, :email, :downcase)
    sent "caf\xC3"                    ArgumentError: invalid byte sequence in UTF-8
    sent UTF-16 / binary text         Encoding::CompatibilityError   (format:, :squish)
  Contract.define(unknown: :error) { ... }
    sent key "caf\xC3"                the 422 itself: JSON::GeneratorError on to_json
    sent a UTF-16 key                 Encoding::CompatibilityError building the path

A standalone Contract#call on a webhook payload has nothing in front of it. A controller using skip_parameter_encoding / param_encoding receives binary or other-encoding Strings on purpose. JSON parsed with allow_nan reaches the NaN case.

The fix

  ["x", 2] against validate: a.sum              ids[0] invalid_type        (validate: not run)
  [1, -4], validate: all positive, transform: sqrt   ids invalid            (transform: not run)
  NaN / Infinity for :integer                   invalid_type
  UTF-16 "12" for :integer / :decimal           12 / 12                    (parsed from a UTF-8 copy)
  Windows-1252 "1\x81" for :integer             invalid_type               (no UTF-8 reading)
  binary "caf\xE9", Shift_JIS, UTF-16 :string   returned unchanged, own encoding
  "caf\xC3" (UTF-8), lone UTF-16 surrogate      invalid_type               (invalid in its OWN encoding)
  UTF-16 " café " with normalize: :squish       left as it is
  UTF-16 / binary "café" against /\Acafé\z/     format
  undeclared key "caf\xC3"                      { param: "caf�", code: "unknown" }
  1. Array 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.
  2. :integer returns invalid_type for a non-finite Float, as :float and :decimal already did.
  3. Other encodings are inspected, never converted:
    • A String invalid in its own encoding is invalid_type for every scalar type.
    • Number, boolean and date casts parse a UTF-8 copy of the text. If there is no UTF-8 reading, that is invalid_type.
    • A :string is returned in its original encoding, bytes unchanged.
    • When format: cannot be applied to a String's encoding, that is a format violation.
    • When a built-in normalize: preset cannot handle the encoding, the value is left as it is. An app's own normalize: Proc always runs and always raises its own errors, on any encoding — this round's fix; see below.
  4. Violation params are always valid UTF-8. Undeclared keys are converted for reporting, and anything that still cannot be read becomes U+FFFD.

No new error codes, so the ERROR_SCHEMA enumeration is unchanged.

Where the checks live

  • Coercion.cast is the one step every scalar value goes through: a field, an of: element, a sub-field of an array of hashes, and an authored default:/example:. It refuses a String that fails valid_encoding?, returns a :string unchanged, and gives the other types a utf8_text copy — 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 an EncodingError/ArgumentError from the regexp into "does not match".
  • apply_normalize skips a String that fails valid_encoding?, so the cast refuses it. For a String that passes that check, only a built-in preset (identified by object identity against NORMALIZERS' values) gets the encoding-failure leniency; an app-supplied Proc's raise always propagates.
  • permittable_unknown_key_violation is the one place a client key enters a path, and the only place reportable_text runs. Declared keys are not converted, and a spec asserts that a clean nested request calls reportable_text zero times. The entry is also remembered by identity. That is how permittable_check_array tells an undeclared key from a failed sub-field, because the code alone cannot: a sub-field's validate: 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:

  1. Real regression: a custom 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-level rescue EncodingError, ArgumentError, so it also caught an early return 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 made normalize: 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 on NORMALIZERS.value?(normalizer) — object identity, since resolve_normalizer! replaces field[:normalize] with the exact Proc from that frozen Hash. A custom Proc's ArgumentError or EncodingError now 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.
  2. Minor perf: a redundant full-string encoding scan on every non-:string scalar field. Coercion.cast called value.valid_encoding? directly, then utf8_text re-checked text.valid_encoding? on the same object when its fast path returned ordinary UTF-8 input unchanged. cast now skips straight to the object it already validated when the encoding is UTF-8, and only calls utf8_text for the encodings where it produces a genuinely new object (US-ASCII, binary, anything needing encode). New spec spies on valid_encoding? via a String subclass and asserts exactly one call per cast.

Behaviour changes worth reviewing

  • The array validate: trade-off. An array-level violation is no longer reported alongside element violations. [1, "x", 1] against a uniqueness validator reports only ids[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_encoding apps 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 own normalize: Proc is unaffected either way — see Regression pass 3.
  • README claims narrowed. It now documents what Contract#call still raises: ArgumentError for input that is not a Hash, nil (read as {}) or to_unsafe_h-able, plus the app's own validate:/transform:/normalize:/finalize code. It no longer claims that the app receives UTF-8.

:json: deliberately not examined

A :json field's nested strings are neither checked nor converted, and a spec pins that down. The gem runs no string operation inside a :json value, 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

  • Left as it is: keys are converted only for reporting, not for matching. A UTF-16 "name" key does not match a declared :name.
  • origin/master is merged into the branch, twice across the review rounds. CHANGELOG conflicts are resolved with master's entries first.

Verification

  • 693 examples, 0 failures. Each review round's specs were written before its implementation: 10, 9, 9, and 2 failing respectively. The rest pin unchanged behaviour: other scalar types, well-formed non-ASCII text, the :json pass-through, the validate: trade-off, the :unknown-returning sub-field, and an app normalizer bug on ordinary UTF-8 still raising. The Contract#call specs check not_to raise_error and the exact violation, and call to_json on a violation for a non-UTF-8 key.
  • RuboCop clean

🤖 Generated with Claude Code

Sang and others added 3 commits September 24, 2026 19:17
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>
@VSN2015

VSN2015 commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Review addressed in 25416d2 (merged master first; fast-forward push):

  1. Every String is read as UTF-8 before any cast: binary via force_encoding on a copy, other encodings via encode, and anything not valid UTF-8 afterwards is invalid_type. UTF-16 "12" is now 12 for :integer and :decimal. Specs cover UTF-16LE, binary with high bytes, and Windows-1252.
  2. Violation params are always valid UTF-8. permittable_path converts client keys and replaces what it cannot read with U+FFFD. Specs call to_json via Contract#call and check the message and the unknown: :log line.
  3. The CHANGELOG now states the array validate: trade-off plainly, and a spec pins it.
  4. The CHANGELOG and README claims now match the code, including a README paragraph on what #call still raises.

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>
@VSN2015

VSN2015 commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Regression pass addressed in 3ae9495 (fast-forward push):

  1. An array's transform: no longer runs after its validate: failed. It runs only when nothing violated, as on master and on the scalar path.
  2. Redesigned to inspect other encodings, never convert them. A String invalid in its own encoding is invalid_type. Numeric, boolean and date casts parse a UTF-8 copy. A :string comes back in its original encoding. An unapplicable format: is a format violation, and an unapplicable normalizer leaves the value as it is. skip_parameter_encoding apps behave as on master, except that former crashes are now violations.
  3. CHANGELOG and README claims rewritten to match.
  4. An undeclared key inside an element no longer stops the array's validate:. Undeclared-key violations are recognised by identity, not by code, because a sub-field's own validate: may return :unknown.
  5. Only undeclared keys go through reportable_text. A spec asserts zero calls for a clean nested request.
  6. README now says Contract#call(nil) reads as {}, and lists which inputs raise ArgumentError.

671 examples, 0 failures; RuboCop clean. PR body updated.

🤖 Generated with Claude Code

Sang and others added 2 commits September 25, 2026 15:02
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>
@VSN2015

VSN2015 commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Final regression pass addressed in f7ccbb4 (merged master; fast-forward push):

  1. Regression fixed: apply_normalize's leniency for an encoding a normalizer can't handle is now scoped to the gem's own presets only, identified by object identity against NORMALIZERS' values. An app-supplied normalize: Proc's own raise (ArgumentError or EncodingError) always propagates, on any encoding, exactly as on master. The bug was a method-level rescue that also caught an early-return call to a custom Proc; the rescue is now scoped to the preset call alone. New spec: a custom Proc raising both error types on Shift_JIS, Windows-1252 and binary input — both propagate.
  2. Perf fixed: Coercion.cast no longer re-scans a UTF-8 String's encoding inside utf8_text when the fast path returns the same object — the check just above already answered it. New spec spies on valid_encoding? via a String subclass and asserts exactly one call per cast for ordinary UTF-8 input.

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
VSN2015 merged commit ff8e893 into master Sep 25, 2026
16 checks passed
@VSN2015
VSN2015 deleted the fix/no-500-on-bad-input branch September 25, 2026 08:27
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant