From ef80be3a0b24bc6c24fc269ad49fa3d268e1a6c6 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 2 Nov 2026 22:13:09 +0500 Subject: [PATCH 1/2] http: decode URI-encoded HTMX request headers HTMX 1.9.x and 2.x URI-encode a header value that the browser cannot send as is and mark it with an additional *-URI-AutoEncoded header. The string properties of HtmxRequestHeaders ignored the marker, so a Cyrillic hx-prompt answer reached the application as %D0%9F%D1%80... Decode the value when the marker is true. A value without the marker is returned as sent, because it may contain a literal percent sign. HTMX 4.x does not mark encoded values, so they are still returned as sent. --- .../HtmxRequestHeaders.cs | 19 ++++++-- .../HtmxRequestHeadersTests.cs | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs index 570de74..7231f00 100644 --- a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs +++ b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs @@ -12,6 +12,8 @@ namespace Ramstack.HtmxToolkit; [DebuggerTypeProxy(typeof(HtmxRequestHeadersDebugView))] public readonly struct HtmxRequestHeaders { + private const string AutoEncodedSuffix = "-URI-AutoEncoded"; + private readonly IHeaderDictionary _headers; /// @@ -66,7 +68,7 @@ internal HtmxRequestHeaders(HttpRequest request) => /// restores this header and URI-encodes its value. /// /// - public string? Prompt => GetString(_headers, HtmxRequestHeaderNames.Prompt); + public string? Prompt => GetString(_headers, HtmxRequestHeaderNames.Prompt, HtmxRequestHeaderNames.Prompt + AutoEncodedSuffix); /// /// Gets a value indicating whether the request has the HX-Request header. @@ -165,7 +167,7 @@ static HtmxRequestType GetRequestType(IHeaderDictionary dictionary) /// Examples include div#results and body. /// /// - public string? Target => GetString(_headers, HtmxRequestHeaderNames.Target); + public string? Target => GetString(_headers, HtmxRequestHeaderNames.Target, HtmxRequestHeaderNames.Target + AutoEncodedSuffix); /// /// Gets the name attribute of the element that triggered the request. @@ -181,7 +183,7 @@ static HtmxRequestType GetRequestType(IHeaderDictionary dictionary) /// with HX-Source instead. /// /// - public string? TriggerName => GetString(_headers, HtmxRequestHeaderNames.TriggerName); + public string? TriggerName => GetString(_headers, HtmxRequestHeaderNames.TriggerName, HtmxRequestHeaderNames.TriggerName + AutoEncodedSuffix); /// /// Gets the ID of the element that triggered the request. @@ -196,7 +198,7 @@ static HtmxRequestType GetRequestType(IHeaderDictionary dictionary) /// has no ID; HTMX 4.x identifies the source element with HX-Source instead. /// /// - public string? Trigger => GetString(_headers, HtmxRequestHeaderNames.Trigger); + public string? Trigger => GetString(_headers, HtmxRequestHeaderNames.Trigger, HtmxRequestHeaderNames.Trigger + AutoEncodedSuffix); /// /// Determines whether the specified header has the value "true". @@ -224,6 +226,15 @@ private static bool GetBoolean(IHeaderDictionary dictionary, string key) => return value; } + private static string? GetString(IHeaderDictionary dictionary, string key, string autoEncodedKey) + { + var value = GetString(dictionary, key); + + return value is not null && GetBoolean(dictionary, autoEncodedKey) + ? Uri.UnescapeDataString(value) + : value; + } + #region Inner type: HtmxRequestHeadersDebugView /// diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs index acdff59..48570a7 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs @@ -45,6 +45,52 @@ public void Prompt_ReturnsHeaderValue() Assert.That(headers.Prompt, Is.EqualTo("yes")); } + [Test] + public void StringProperties_AreDecoded_WhenHeaderIsAutoEncoded() + { + var headers = CreateHeaders( + (HtmxRequestHeaderNames.Prompt, "%D0%94%D0%B0%20100%2525"), + (HtmxRequestHeaderNames.Prompt + "-URI-AutoEncoded", "true"), + (HtmxRequestHeaderNames.Target, "%D1%86%D0%B5%D0%BB%D1%8C"), + (HtmxRequestHeaderNames.Target + "-URI-AutoEncoded", "true"), + (HtmxRequestHeaderNames.Trigger, "%D1%84%D0%BE%D1%80%D0%BC%D0%B0"), + (HtmxRequestHeaderNames.Trigger + "-URI-AutoEncoded", "true"), + (HtmxRequestHeaderNames.TriggerName, "%D0%B8%D0%BC%D1%8F"), + (HtmxRequestHeaderNames.TriggerName + "-URI-AutoEncoded", "true")); + + Assert.That(headers.Prompt, Is.EqualTo("Да 100%25")); + Assert.That(headers.Target, Is.EqualTo("цель")); + Assert.That(headers.Trigger, Is.EqualTo("форма")); + Assert.That(headers.TriggerName, Is.EqualTo("имя")); + } + + [Test] + public void Prompt_IsNotDecoded_WithoutAutoEncodedHeader() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.Prompt, "100%25")); + Assert.That(headers.Prompt, Is.EqualTo("100%25")); + } + + [Test] + public void Prompt_IsNotDecoded_WhenAutoEncodedHeaderIsNotTrue() + { + var headers = CreateHeaders( + (HtmxRequestHeaderNames.Prompt, "100%25"), + (HtmxRequestHeaderNames.Prompt + "-URI-AutoEncoded", "false")); + + Assert.That(headers.Prompt, Is.EqualTo("100%25")); + } + + [Test] + public void Prompt_IsNotDecoded_WhenOtherHeaderIsAutoEncoded() + { + var headers = CreateHeaders( + (HtmxRequestHeaderNames.Prompt, "100%25"), + (HtmxRequestHeaderNames.Trigger + "-URI-AutoEncoded", "true")); + + Assert.That(headers.Prompt, Is.EqualTo("100%25")); + } + [Test] public void Request_ReturnsHeaderValue() { From 6eb2997e0ce7fbdeea4c1a45156ecd1b717c9322 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 2 Nov 2026 22:21:22 +0500 Subject: [PATCH 2/2] http: document decoding of URI-encoded request headers --- docs/articles/migrate-to-v2.md | 11 +++++++++++ docs/articles/requests.md | 7 +++++-- src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs | 17 ++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index 578b10e..2d679ce 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -310,6 +310,17 @@ instead of the page after Back or Forward. In 2.0, the same code returns the ful HTMX 1.9.x and 2.x ignore response headers when restoring history, so skipping them loses nothing. To read the raw header, use `Request.GetHtmxHeaders().Request`. See [Handle history restoration](requests.md#handle-history-restoration). +### Encoded header values are decoded + +> [!WARNING] +> `Prompt`, `Target`, `Trigger`, and `TriggerName` now return the decoded value when HTMX URI-encoded it. +> This is a silent behavior change. + +HTMX 1.9.x and 2.x URI-encode a header value that the browser cannot send as is, such as a Cyrillic `hx-prompt` answer +or element ID, and mark it with an additional `*-URI-AutoEncoded` header. In 1.x, these properties returned the encoded value. +Remove any manual `Uri.UnescapeDataString` call for them: decoding twice corrupts a value that contains `%`. +HTMX 4.x does not mark encoded values, so they are still returned as sent; see [Request header properties](requests.md#request-header-properties). + ### Boosted requests | 1.x | 2.0 | diff --git a/docs/articles/requests.md b/docs/articles/requests.md index ca580e3..9875d97 100644 --- a/docs/articles/requests.md +++ b/docs/articles/requests.md @@ -59,8 +59,11 @@ Missing or version-inapplicable string headers return `null`. `Request` is `true` whenever the `HX-Request` header is present, whatever its value; unlike `IsHtmxRequest()`, it does not exclude history restoration. `Boosted` and `HistoryRestoreRequest` are `true` only when their header is exactly `true`. -In HTMX 4.x, the `hx-prompt` extension sends the prompt answer URI-encoded; -decode it with `Uri.UnescapeDataString` before use. +HTMX 1.9.x and 2.x URI-encode a header value that the browser cannot send as is, such as a Cyrillic prompt answer +or element ID, and mark it with an additional `*-URI-AutoEncoded` header. `Prompt`, `Target`, `Trigger`, and `TriggerName` +return such a value decoded. +HTMX 4.x does not mark encoded values: the `hx-prompt` extension URI-encodes the prompt answer, and `Target` and `Source` +contain a URI-encoded element ID. Decode these values with `Uri.UnescapeDataString` before use. `RequestType` returns `HtmxRequestType.Full` for a history restoration request without its header, and `HtmxRequestType.Unspecified` for any other request without it. diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs index 7231f00..db81367 100644 --- a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs +++ b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs @@ -9,6 +9,12 @@ namespace Ramstack.HtmxToolkit; /// /// Represents strongly typed HTMX request headers. /// +/// +/// HTMX 1.9.x and 2.x URI-encode a header value that the browser cannot send as is, such as Cyrillic text, +/// and mark it with an additional *-URI-AutoEncoded header. , , +/// , and return such a value decoded. +/// HTMX 4.x URI-encodes some values without marking them, so the properties return those values as sent. +/// [DebuggerTypeProxy(typeof(HtmxRequestHeadersDebugView))] public readonly struct HtmxRequestHeaders { @@ -213,7 +219,7 @@ private static bool GetBoolean(IHeaderDictionary dictionary, string key) => dictionary.TryGetValue(key, out var value) && value is ["true"]; /// - /// Gets the value of the specified header. + /// Returns the value of the specified header. /// /// The header collection to inspect. /// The name of the header. @@ -226,6 +232,15 @@ private static bool GetBoolean(IHeaderDictionary dictionary, string key) => return value; } + /// + /// Returns the value of the specified header, decoded if HTMX marked it as URI-encoded. + /// + /// The header collection to inspect. + /// The name of the header. + /// The name of the header that marks the value as URI-encoded. + /// + /// The header value, or if the header is not present. + /// private static string? GetString(IHeaderDictionary dictionary, string key, string autoEncodedKey) { var value = GetString(dictionary, key);