Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ Contracts that don't opt in are byte-for-byte unaffected: the default format is
Other encodings are now **inspected, never converted**. A String whose bytes are not valid in its own encoding is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. A `:string` value is otherwise handed back exactly as it arrived, in its own encoding, so a `skip_parameter_encoding` controller behaves as it did before this release, except that its former crashes are now violations. The number, boolean and date types parse a UTF-8 copy of the text: UTF-16 `"12"` is `12`, and text with no UTF-8 reading (a byte Windows-1252 leaves undefined) is `invalid_type`. A normalizer that cannot handle a String's encoding (`:squish` on UTF-16) leaves the value as it is, and a `format:` pattern that cannot be applied to it (non-ASCII pattern, UTF-16 or binary bytes) reports `format`. Neither ever raises for such a String; on UTF-8 or ASCII-only text an app's own `normalize:` proc that raises still raises. The check sits in the one cast every scalar value goes through, so it covers `of:` elements, sub-fields of arrays of hashes and authored `default:`s. The codes are the existing `invalid_type` and `format`, so the `code` enumeration in the exported error schema is unchanged. A `:json` field's contents are deliberately not examined. The gem runs no string operation inside them, and walking every leaf on every request would add the cost the opaque type exists to avoid.
- **An undeclared key that was not valid UTF-8 made the 422 itself fail to render.** Under `unknown: :error` the client's key was copied raw into the violation's `param`, so `"caf\xC3"` made `to_json` raise `JSON::GeneratorError`, and a UTF-16 key raised `Encoding::CompatibilityError` while its path was being built, under `unknown: :log` too. An undeclared key is now converted to UTF-8 for reporting (binary read as UTF-8, other encodings via `encode`), and whatever still cannot be read is replaced with U+FFFD, so `param`, the exception message and the log line are always valid UTF-8, nested keys (`user.x\uFFFD`) included. Only undeclared keys are converted; the declared keys a request walks through are the contract's own names and cost nothing extra.
- **A client-sent key could forge a log entry.** The `unknown: :log` warn line, the monitor-mode warn line and `InvalidParameters#message` all interpolate the names of the keys a request sent, raw — and 0.8.0's prose bounds capped how *many* and how *long*, but escaped nothing. A key of `"evil\nE, [2026-09-23] ERROR -- : forged admin login"` therefore wrote a second, entirely fake `ERROR` line into the log. A name containing an unsafe character is now printed quoted and escaped: `"evil\nE, [2026-09-23] ERROR -- : forged admin login"`, with `\n`/`\r`/`\t` short and anything else as `\uXXXX` (`\u{XXXXX}` beyond the BMP). Unsafe means, by Unicode property: every control character (`Cc` — C0, DEL and C1, which holds NEL and the 8-bit terminal CSI); the line and paragraph separators (`Zl`, `Zp`); every format character (`Cf` — the bidi embeddings, overrides and isolates, which can visually reorder a line and hide where a quoted name ends, and the zero-width and marker characters such as U+200B, U+200E/U+200F, U+061C and U+FEFF, which make two names print identically); and every space other than U+0020 (`Zs`), so `x,<NBSP>and 49990 more` cannot pass for a separator. A name that only *looks* like prose structure is quoted too, its characters printed as they are: one containing any Unicode `Quotation_Mark` (`"`, the fullwidth `"`, curly quotes, guillemets, and the CJK corner brackets `「`/`」`, real quotation marks in Japanese and Chinese text that an earlier, narrower `Pi`/`Pf`-only check missed); one containing the list separator `, ` or a fullwidth, small, ideographic or small-form-ideographic comma (`,` `﹐` `、` `﹑`), so it cannot pass for two names; and one beginning `and N more` in any letter case (`And`/`AND` reads identically once rendered), so two keys `b` and `and 49990 more` — or `And 49990 more` — cannot fake the overflow count. That last check still only catches the literal word: it defeats case and punctuation lookalikes, not a homoglyph substitution such as Cyrillic `а` (U+0430) for Latin `a`, and no general Unicode confusable-detection is attempted here — nobody should rely on this guard as a complete defense against a determined lookalike. Inside a quoted name the quote and backslash are escaped. Only the client-sent name is ever escaped: a field's `message:` (or its I18n copy) is the developer's text and is printed as is, so a YAML `|` message ending in a newline does not quote the names it follows. A name is judged by the part the 120-character truncation leaves visible, so a control character past the cut quotes nothing. A quoted name is cut only when it does not fit the limit on its own, between whole escapes and with the `...` outside the closing quote; a quoted name that fits stays whole and its suffix is cut instead — to nothing, if need be, in which case the `...` marking the cut may run up to three characters past the limit. Only such names change: an ordinary name — non-ASCII, backslashes and a bare comma included — prints as before, now always as UTF-8. A key in a legacy encoding is transcoded character by character: what maps is converted (a Windows-1252 `é` prints as `é`, a Latin-1 `0x85` as its NEL escape), and only a byte that has no mapping — Windows-1252 leaves `0x81`, `0x8D`, `0x8F`, `0x90` and `0x9D` undefined — prints as `\xNN`, as do bytes that are not valid UTF-8. The prose list is always joined from UTF-8 items, so names in mixed encodings no longer raise `Encoding::CompatibilityError` there (joining a nested key's path is covered by #61). Only a bounded prefix of each name is read, so a 1 MB name costs no more than a short one. **`InvalidParameters#message` is also what API clients read** — the envelope's `message` and the problem+json `detail` — so a client that sent such a key sees the escaped rendering there too. The machine-readable channels — `details`, the problem+json `errors`, and the instrumentation payload — are data, not prose: an unknown key that is not valid UTF-8 is reported there as `Coercion.reportable_text` reads it (scrubbed to U+FFFD, per #61), so they stay JSON-safe rather than as legible as this escaping can make it, and prose (the log line and the exception message alike) reads the raw key itself, so a client sees the finer `\xNN` transcoding in the message even though `details` shows the plainer, scrubbed form for the same violation.
- **`in:` members are now cast with the field's own type, at class load.** The runtime compared the *cast* request value against the members *as authored*, so `in: %i[draft published]` on a `:string` field compared `"draft"` with `:draft` and answered `inclusion` to **every** request — while the exported schema, which stringifies Symbols, advertised `"enum": ["draft", "published"]`, the very values the server refused. `in: %w[1 2 3]` on an `:integer` field rejected every value the same way. Each member of a **list** — an `Array`, `Set`, `Enumerator` (`Lazy` included, forced once rather than cast per request), or a `Hash` read as its keys, which is what `Hash#include?` always asked about — now goes through the same cast a request value does: a Symbol is read as its String, and `normalize:` is not applied, since it rewrites what a client sent rather than what the contract says. The contract stores the cast members frozen and deduplicated; a `Set` or a `Hash`'s keys are stored as a `Set`, so membership stays O(1) per request. The Rails enum idiom `in: Post.statuses` therefore keeps working, and satisfies `check_column_types`' enum rule, which reads the stored keys. Request-time matching, the exported `enum` (which for `%w[1 2 3]` on an `:integer` is now `[1, 2, 3]`, not `["1", "2", "3"]`), the column guard and the RSpec matcher all read that one list; `within` reads its own argument with the same two functions, and compares lists as sets, so `within(%i[draft published])` can repeat the declaration as written. A `nil` member is dropped on a `nullable:` field, where an explicit null is accepted before `in:` is consulted. A `Time` or `DateTime` member of a `:date` field is read as its date when it is exactly midnight UTC — the one instant ActiveSupport ever found equal to a date.
- **Any other object answering `include?` is still used exactly as given**, Enumerable or not — an app's own case-insensitive allowlist, or a DB-backed registry, is never enumerated at class load nor replaced by an exact-match copy. This includes a `Hash`/`Array`/`Set` **subclass that overrides `include?`**: `case allowed; when Hash ...` matches with `===`, which for a Class is `is_a?`, so a subclass first matched its ancestor's branch and had its override silently discarded, read for its raw keys/elements instead — inverting which values it actually accepted, with no error at class load. Only a plain `Array`, `Set`, `Hash`, or `Enumerator` (an unoverridden `include?`) is now read as a list; `ActiveSupport::HashWithIndifferentAccess` is kept as one anyway, since its own override only canonicalises the argument before the same key lookup, and it is what a Rails enum's own reader (`Post.statuses`) actually returns. Any other override is not cast, and is exported as `x-permittable-custom-validation` rather than an `enum` it cannot list.
- **Every exported `:date`/`:datetime` member is one the server accepts.** A member written as a String is published as written, and a `Time`/`DateTime`/`TimeWithZone` member is published with as many fractional-second digits as it has (up to nine). Re-encoding printed whole seconds, so `in: ["2026-09-05T10:00:00.25Z"]` published `"2026-09-05T10:00:00Z"`, a value the server refused. The same encoding applies to an exported `:datetime` `default:`/`example:`. A spec sends every exported member back through the contract.
- **`in:` can no longer be a `String`.** The only check was `respond_to?(:include?)`, which a `String` passes — and `String#include?` is a substring test, so `in: "free pro"` accepted `"e"`, `"fr"` and `"ee p"` as plans. A `String` now fails at class load, as anything that is neither a `Range` nor answers `include?` already did.
- **An `in:` `Range` the field's values cannot be compared with fails at class load.** `in: "1".."5"` on an `:integer`, or `in: 1..5` on a `:string`, made `cover?` answer `false` for every value. A Range is deliberately **not** cast — casting would change what it means (`0..Float::INFINITY` on a `:float` and `1.5..3` on an `:integer` are real bounds no cast accepts, and a `:decimal`'s `0..100` would export its `minimum` as the string `"0.0"`) — so it is kept exactly as written, and refused only when its endpoints cannot be compared with a value of the field's type, asked the way `cover?` itself asks.

**A list is now a snapshot taken at class load.** Until now an `in:` Array was read live, so a constant mutated after the class loaded — `PLANS << "gold"` in an initializer — was seen by later requests. It is now cast and frozen once, so that mutation is not seen. Declare the full list before the contract loads, or pass an object of your own answering `include?`, which is still read on every request.

The cast only **loosens** what a request is judged against: no request a contract accepted before is refused now. These declarations used to boot and now fail at class load, each because something in it could never match — though a list's other, valid members did match before, so a contract such as `in: [1, 2, "three"]` was partly working, not wholly broken:
- a `String` `in:` — it matched substrings; write it as a list, `in: %w[free pro]`;
- a list member the field's type cannot cast (`"three"` in `in: [1, 2, "three"]` on an `:integer`), including `nil` on a field that isn't `nullable:` (the error names `nullable: true`), and a `Time`/`DateTime` member of a `:date` field that is not exactly midnight UTC;
- `in: [nil]` on a `nullable:` field, which lists nothing once the `nil` is dropped (an explicit null needs no `in:`);
- a `Range` whose endpoints the field's values cannot be compared with (`in: "1".."5"` on an `:integer`) — this one did reject every value.

One exported-docs change for lists that already worked: the `enum` now carries the field's own encoding, so `in: [1.5]` on a `:decimal` exports `"1.5"` (the same precision-safe string its `default:` already exports) and `in: [1, 2]` on a `:float` exports `1.0, 2.0`.

## 0.8.0 (2026-09-19)
<!-- title: a no-op sensitive: cascade, an invalid OpenAPI export, and megabyte-scale rejections -->
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ Which options are legal depends on the field kind — anything else raises at cl

| Option | Scalar | Array | Nested | Meaning |
|---|:---:|:---:|:---:|---|
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
| `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
Expand Down Expand Up @@ -910,7 +910,7 @@ RSpec.describe UsersController do
end
```

Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`).
Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`, cast by the field's type just as the contract's list is, so `within(%i[draft published])` repeats the declaration as written), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`).

The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`.

Expand Down Expand Up @@ -1020,7 +1020,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
| `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
| `:date` / `:datetime` | `string` + `format: date` / `date-time` |
| `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
| `in:` Array / numeric Range | `enum` / `minimum` + `maximum` (exclusive ends honoured) |
| `in:` list / numeric Range | `enum` of the cast members (a `:date`/`:datetime` member written as a String is published as written) / `minimum` + `maximum` (exclusive ends honoured). An `in:` object that only answers `include?` is flagged `x-permittable-custom-validation` |
| `length:` | `minLength`/`maxLength` on strings, `minItems`/`maxItems` on arrays |
| `format:` | `pattern`, valid under the `u` flag Ajv compiles with: `\A`/`\z` become `^`/`$`, `\s` and `.` are spelled out as the classes they are in Ruby (ECMA-262's `\s` also matches NBSP and U+2028; its `.` also stops at `\r`), and redundant escapes like `\-` and `\#` are written bare |
| `default:` / `desc:` / `example:` | `default` / `description` / `examples` |
Expand Down Expand Up @@ -1088,7 +1088,8 @@ A bad contract is a programmer error, so it fails when the class loads — never
- An unknown `normalize:` or `format:` preset, listing the presets
- A `format:` that is neither a `Regexp` nor a preset name
- `format:`, `length:`, or `normalize:` on a non-`:string` field
- `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that doesn't respond to `include?`
- `length:` that isn't a non-negative `Integer` or a `Range`; an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
- An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion`
- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
- `validate:` or `transform:` that isn't callable
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept
Expand Down
Loading
Loading