Skip to content

fix(request): decode Query and ContentFields values exactly once - #570

Open
freitasjca wants to merge 3 commits into
HashLoad:masterfrom
freitasjca:fix/decode-once
Open

freitasjca wants to merge 3 commits into
HashLoad:masterfrom
freitasjca:fix/decode-once

Conversation

@freitasjca

Copy link
Copy Markdown
Contributor

Problem

Req.Query[...] and Req.ContentFields[...] decode a value that was already URL-decoded, and do it again on every read. With the default Indy provider:

Request Req.Query['v'] today Expected
?v=100%25 EConvertError: Error decoding URL style (%XX) encoded string at position 4 → HTTP 500 100%
?v=50%25off EConvertError: Invalid URL encoded character (%of) at position 3 → HTTP 500 50%off
?v=a%2B%2541 a A (no error, wrong value) a+%41

It was reported to us as an intermittent HTTP 500. It's intermittent because DecodeParam skips values without a %, so it only fails when the data contains one.

Cause

Every path that fills these collections already stores decoded values:

  • InitializeQuery calls DecodeParam on each key and value before storing them.
  • WebBroker's ContentFields are decoded by ExtractHTTPFields.
  • Providers that fill the dictionaries themselves store decoded values.

Since 3.3.0, THorseCoreParam.GetItem / TryGetValue also run DecodeParam on read, then write the result back, so each repeated read decodes again. Field(...).AsString reads the dictionary directly and always returned the right value, so two accessors disagree on the same request.

Fix

InitializeQuery and InitializeContentFields now create THorseCoreParam with ADecodeValues = False, which InitializeParams already does. Two lines in src/Horse.Request.pas, no other behaviour change.

Tests

  • tests/src/tests/Tests.Horse.Request.DecodeOnce.pas (unit): stores decoded values in Query, ContentFields and Params and checks that Field, [v], a repeated [v] and TryGetValue return them unchanged.
  • tests/src/tests/Tests.Integration.QueryDecode.pas (integration, port 9126): the table above through the default provider, plus a form-urlencoded PUT.
  • Both registered in Console.dpr.

Validated on Windows / Delphi 12 (Indy):

  • On 3.3.5 without the fix, the 10 expected new tests fail.
  • With the fix, all 13 new tests pass (202 → 212 of 221). The remaining failures in the suite are identical with and without this change.

Not run on FPC. The change is the same constructor call InitializeParams already makes.

InitializeQuery and InitializeContentFields now create THorseCoreParam with
ADecodeValues=False, as InitializeParams already does.

Every store path already holds DECODED values: InitializeQuery runs
DecodeParam on each key and value, WebBroker ContentFields are decoded by
ExtractHTTPFields, and non-WebBroker providers store decoded values. But
GetItem/TryGetValue decoded AGAIN on every read and wrote the result back:
  ?v=100%25    -> EConvertError "Error decoding URL style (%XX) encoded string"
  ?v=50%25off  -> EConvertError "Invalid URL encoded character (%of)"
  ?v=a%2B%2541 -> silently "a A" instead of "a+%41"
Field(...).AsString read the stored value and was always correct. This affects
the default Indy provider too. User-reported as an intermittent HTTP 500.

Validated on Windows/Delphi with a three-way read test (Field, [v], [v]
again): 131 passed / 8 failed before, 139/139 after, on top of 3.3.5.
Tests.Horse.Request.DecodeOnce (unit, no network): stores already-decoded
values in THorseRequest.Query / ContentFields / Params and asserts that Field,
the first indexed read, a repeated indexed read and TryGetValue all return
them unchanged. Cases: '100%', '50%off', 'a+%41', plus a no-percent control.
'a+%41' fails without the fix on FPC too, where HTTPDecode may not raise.

Tests.Integration.QueryDecode (port 9126): GET ?v=100%25 / 50%25off /
a%2B%2541 and a form-urlencoded PUT v=100%25 through the default provider;
the handler reads each value twice and then via Field.

Validated on Windows/Delphi: 10 new failures on 3.3.5 (EConvertError
"Error decoding URL style (%XX)", "Invalid URL encoded character", silent
"a A", HTTP 500s), all 13 new tests pass with the fix. The remaining
suite failures are identical with and without it.
freitasjca added a commit to freitasjca/horse-provider-mormot that referenced this pull request Sep 14, 2026
… pairs

FIX-DECODE-ONCE-1 companion to HashLoad/horse#570. Query and
application/x-www-form-urlencoded keys and values are now URL-decoded when
stored, like every other Horse provider. The decoder is tolerant: malformed %xx
is kept, + is a space, and invalid UTF-8 falls back to the raw text, because it
runs outside any handler's try/except.

Also fixes form-urlencoded parsing: the WHOLE "name=value" text was stored as
the key with an empty value, so Req.ContentFields['name'] always returned ''.

Tests 41-45 (same as horse-provider-crosssocket), plus the HARNESS-CB-1 test
client fix (SetEvent in finally, callback exceptions reported as status -1).

Validated on Windows/Delphi: 15 new failures before the fix, 124/124 after,
with the #570 Horse fix. Against an unpatched Horse, Req.Query[...] still
double-decodes, but Field() and form fields are now correct.
freitasjca added a commit to freitasjca/horse-provider-ics that referenced this pull request Sep 14, 2026
FIX-DECODE-ONCE-1 companion to HashLoad/horse#570 (Horse.Provider.ICS.Request):
query and form-urlencoded keys and values are decoded once at store time with a
tolerant decoder. Form parsing stored the whole "name=value" as the key, so
Req.ContentFields['name'] was always ''.

FIX-ICS-UTF8-BODY (Horse.Provider.ICS): ICS's AnswerString encodes the body with
TEncoding.Default, which is ANSI on Windows, so non-ASCII response characters
went out as ANSI under charset=utf-8. All three call sites now use
AnswerBodyTB(TEncoding.UTF8.GetBytes(Body)), public since ICS 9.2 and on the
same AnswerStream path. POSIX was unaffected.

Tests: 41-45 added. HARNESS-CB-1 test client fix (SetEvent in finally,
callback exceptions reported as status -1). Test 15 now sends Content-Length: 0:
TCrossHttpClient omits the header for an empty body, and ICS rejects a POST
without it with its own HTML 400 before the provider runs (curl: 10/10), which
left the test's body check unreachable.

Validated on Windows/Delphi: 117/117 with the #570 Horse fix.
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