Skip to content

Check length: before in: and format: - #25

Open
VSN2015 wants to merge 1 commit into
masterfrom
fix/check-length-before-format
Open

Check length: before in: and format:#25
VSN2015 wants to merge 1 commit into
masterfrom
fix/check-length-before-format

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Same question as #24, different bound: does the cheap check run before the expensive one?

The problem

length: is an O(1) read of a String's size. format: runs a regexp over the whole value; validate: runs arbitrary app code. Checking the cheap bound last meant a value the bound already excluded still paid for the expensive ones.

A 5 MB string against an entirely ordinary declaration:

required :email, :string, length: 1..80, format: URI::MailTo::EMAIL_REGEXP
Time
Before 121 ms — the regexp scanned all 5 MB (82 ms of it), and the result was then thrown away by the length check
After 38 ms — the regexp is never consulted (the residual is HashWithIndifferentAccess conversion of the payload)

With a well-behaved regexp that is waste. With an app regexp that has poor worst-case behaviour it is a lever, available to anyone who can send a long string to a bounded field — which is every field that declares a length at all.

The fix

normalize:  →  cast  →  length:  →  in:  →  format:  →  validate:

First failure reported, and the order is now documented in the README.

The one observable change

A value violating both its length and its format reports length instead of format/inclusion. That's the more useful answer anyway — a client can't act on "wrong format" for a value that is also far too long.

Nothing else moves: a field with no length: checks format: exactly as before, and a value inside its length bound still gets the full set of checks in order.

How the spec proves it

Asserting the reported code would only show format was outranked, not that it was skipped. So the spec passes a Regexp subclass that records whether match? was called — satisfying any format: type check while staying observable:

expect(e.details).to eq([{ param: "s", code: "length" }])
expect(spy.consulted?).to be(false)

and the companion spec asserts it is consulted for a value within the bound.

Verification

  • 203 examples, 0 failures (4 new, written before the change)
  • RuboCop clean

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #25: Sensible check ordering optimization. Running O(1) string length checks prior to regex format evaluation and custom validation callbacks prevents ReDoS and wasted processing on oversized strings.

Comment thread lib/permittable.rb
# failure first is the better answer anyway — a client cannot act on
# "wrong format" for a value that is also far too long.
def check_scalar_rules(field, value)
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking length: before in: and format: protects regex matching from having to evaluate giant string inputs when the length bound has already failed.

Comment thread spec/permittable_spec.rb
end
end.new("\\A[a-z]+\\z")
end

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a spy_format subclass of Regexp to verify that match? is never invoked on length-violating inputs is a great test design.

length: is an O(1) read of a String's size. format: runs a regexp over
the whole value and validate: runs arbitrary app code. Checking the
cheap bound LAST meant a value the bound already excluded still paid
for the expensive ones:

  5 MB string against `length: 1..80, format: EMAIL_REGEXP`
  before: 121 ms — the regexp scanned all 5 MB (82 ms of it) and the
          result was then discarded by the length check
  after:   38 ms — the regexp is never consulted

With a well-behaved regexp that is waste. With an app regexp that has
poor worst-case behaviour it is a lever, handed to whoever can send a
long string to a bounded field — which is every field that declares a
length at all.

The documented order is now

  normalize: -> cast -> length: -> in: -> format: -> validate:

with the first failure reported. The only observable change is which
code a value violating BOTH reports: length rather than
inclusion/format. That is the better answer anyway — a client cannot
act on "wrong format" for a value that is also far too long.

The spec proves the regexp is genuinely skipped rather than merely
outranked, using a Regexp subclass that records whether it was
consulted, so it satisfies any format: type check while still being
observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the fix/check-length-before-format branch from 2b182e6 to d6b2207 Compare September 11, 2026 22:01
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