Tag path params as UTF-8 instead of leaving them binary - #2839
Merged
Conversation
ericproulx
force-pushed
the
fix/path-param-encoding
branch
from
July 29, 2026 20:59
b3d9d12 to
f70f13c
Compare
Danger ReportNo issues found. |
ericproulx
force-pushed
the
fix/path-param-encoding
branch
from
August 1, 2026 11:13
f70f13c to
2fffb2e
Compare
4 tasks
ericproulx
force-pushed
the
fix/path-param-encoding
branch
2 times, most recently
from
August 1, 2026 20:15
720e518 to
80e9e99
Compare
ericproulx
force-pushed
the
fix/path-param-encoding
branch
2 times, most recently
from
August 20, 2026 07:05
b65af91 to
31e68b6
Compare
dblock
approved these changes
Aug 20, 2026
dblock
left a comment
Member
There was a problem hiding this comment.
See comments below, but LGTM at a high level.
ericproulx
force-pushed
the
fix/path-param-encoding
branch
2 times, most recently
from
August 21, 2026 10:09
b424242 to
af8d5b9
Compare
dblock
approved these changes
Aug 21, 2026
dblock
left a comment
Member
There was a problem hiding this comment.
Add some code that shows how to restore old behavior in UPGRADING and this is good to go. I wouldn't add an option.
Mustermann decodes path captures out of PATH_INFO, which Rack hands over
tagged ASCII-8BIT, and nothing re-tagged the result. Query and body params
arrive UTF-8 because Rack tags those itself, so the same value reached the
endpoint with a different encoding depending on where it came from.
That made an API's declarations disagree with themselves -- a binary string
never equals the UTF-8 literal it was written as:
params { requires :id, type: String, values: ['café'] }
GET /?id=café -> 200
GET /café -> 400 "id does not have a valid value"
The same held for same_as, except_values and any comparison an endpoint made
against a non-ASCII literal. It also leaked into serialization: a non-ASCII
path param rendered into a JSON response drew an encoding warning from the
json gem, which that gem says will become an error in json 3.0.
Re-tag in Route#params_for, the single funnel for path-extracted values.
Nothing obliges a client to send UTF-8 -- HTTP treats the request target as
octets, and Rack's SPEC has CGI keys carry non-ASCII as ASCII-8BIT -- so
UTF-8 is the convention rather than a guarantee: it is what browsers
percent-encode with, what an IRI maps to, and what Rails settles on
(ActionDispatch::Journey::Router force_encodes every path capture to UTF-8
after unescaping it). Only the encoding changes here: the bytes are
untouched, so octets that are not UTF-8 stay invalid and are still caught
downstream instead of being silently scrubbed into something the client
never sent. Unnamed splats capture into an Array, so those are walked too.
The re-tag is unconditional and in place, hence tag_utf8!. That is safe
rather than merely cheap: Mustermann's Pattern#params builds a fresh Hash of
fresh, unfrozen strings on every call and skips its own Match cache, so the
mutation cannot escape the request. It is also what lets the Array branch
re-tag its elements with each.
Since every capture now has to be visited anyway, params_for drops the
compact/symbolize_keys chain and does the whole job in one pass, building
the Hash directly instead of allocating two intermediates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAXGEq9nJgFJvG4qF95bum
ericproulx
force-pushed
the
fix/path-param-encoding
branch
from
August 22, 2026 15:18
af8d5b9 to
27c5f4e
Compare
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.
Summary
Mustermann decodes path captures out of
PATH_INFO, which Rack hands over taggedASCII-8BIT, and nothing re-tagged the result. Query and body params arriveUTF-8because Rack tags those itself — so the same value reached the endpoint with a different encoding depending on where it came from.That made an API's own declarations disagree with themselves, since a binary string never equals the UTF-8 literal it was written as:
GET /?id=café(query)GET /café(path)"id does not have a valid value"The same held for
same_as,except_values, and any comparison an endpoint made against a non-ASCII literal. It also leaked into serialization: a non-ASCII path param rendered into a JSON response drewwarning: JSON.generate: UTF-8 string passed as BINARY, which thejsongem says will become an error in json 3.0.Approach
Re-tag in
Route#params_for, the single funnel for path-extracted values.Why UTF-8, given the path is octets. Nothing obliges a client to send UTF-8: HTTP treats the request target as octets, and Rack's SPEC has CGI keys carry non-ASCII as
ASCII-8BIT— which is exactly whyPATH_INFOarrives binary in the first place. UTF-8 is the convention rather than a guarantee: it is what browsers percent-encode with, what an IRI maps to, and what Rails settles on —ActionDispatch::Journey::Routerforce-encodes every path capture to UTF-8 after unescaping (action_dispatch/journey/router.rb:79, actionpack 8.1.3.1):Only the encoding changes — the bytes are untouched, so octets that are not UTF-8 stay detectably invalid and are still caught downstream rather than being silently scrubbed into something the client never sent. Unnamed splats capture into an
Array({"splat" => ["a", "b"]}), so those are walked too.Backward compatibility
UPGRADING entry added. Comparisons against pure-ASCII strings are unaffected, which is the overwhelming majority of code. Code that relied on a path param being binary — concatenating one with genuinely binary data — can now raise
Encoding::CompatibilityErrorand should call.bon the param. An app that already worked around this with its ownforce_encodingneeds no change; that call becomes a no-op.For an app with too many such sites to patch individually, UPGRADING also documents a wholesale opt-out — a
beforefilter at the top of the root API:env['grape.routing_args']is populated fromparams_foralone, so this touches only path params and leaves query and body params alone;:route_infois skipped because it is theRouteobject sharing that hash.beforeruns ahead of validation (endpoint.rb:167vs:178), so declarations see the binary strings too — verified byvalues: ['café']going back to400onGET /caféwith the filter installed. Declared in the root API it covers mounted APIs as well, and theArrayline matters for splats, whereparams[:splat]is anArray<String>.It is framed there as a migration aid rather than a setting, since it reinstates exactly the inconsistency this PR fixes.
Perf
The re-tag is unconditional and in place — Mustermann's
Pattern#paramsbuilds a freshHashof fresh, unfrozen strings on every call and skips its ownMatchcache, so nothing here is shared or frozen andforce_encodingmutates directly. The tagging itself is therefore allocation-free.Since every capture has to be visited anyway,
params_fornow does the whole job in one pass, building the resultHashdirectly instead of allocating the two intermediates thatcompactandsymbolize_keysproduced. Net, this branch allocates less than master while doing strictly more work.Measured against
22d79756, same benchmark both sides.Route#params_forin isolation — two captures, binary input:Full request — four route shapes:
Allocations drop by exactly one object per request on every shape — an integer count with no variance, so it is the trustworthy signal here. The isolated 6.8% is the
force_encodingper capture, butparams_foris ~1.65 µs of a ~12.4 µs request, so it works out to ~0.9% of the request, below the noise floor. I ran the request-level comparison interleaved three times to confirm: every difference stayed inside the ±4–6% error bars.One note for anyone reproducing this. The obvious one-pass form,
each_with_object({}), is slower than the chain it replaces —Hash#each_with_objectpacks every entry into a pairArraybefore yielding, so it costs 4 objects/call against the chain's 2, andfilter_map { … }.to_his worse still at 6. Building into an explicitHashwith a two-arityeachblock skips the pair allocation and is the only form that actually wins.Test plan
api_spec.rbcovering single/multiple/splat/unnamed-splat captures, thevalues:equality case, and a byte-preservation invariant; verified the behavioural ones fail without thelib/change.🤖 Generated with Claude Code