From 4540ae1913097c2134af71687e7663f3b6ec1e58 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 28 Oct 2026 15:22:33 +0500 Subject: [PATCH 1/3] http: escape non-ASCII characters in HX-Trigger and HX-Location JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON written into response headers used the encoder meant for HTML attributes, which leaves all Unicode ranges unescaped. Response headers allow only ASCII, so a non-ASCII event name, event detail, or HX-Location option, such as TriggerEvent("saved", new { Name = "Привет" }), failed with "Invalid non-ASCII or control character in header" and the request returned 500. This is a regression from 1.2.2. Write header JSON with the default encoder, as 1.2.2 did. HTML attributes keep the encoder that preserves Unicode characters. --- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 5 +- .../Internal/PendingEvents.cs | 5 +- ...tmxLocationOptionsJsonSerializerContext.cs | 21 ++------ .../Serialization/JsonOptions.cs | 18 ++++--- .../HtmxResponseTests.cs | 54 +++++++++++++++++++ .../Internal/PendingEventsTests.cs | 28 ++++++++++ 6 files changed, 104 insertions(+), 27 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 405fc2e..1d1825a 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -373,8 +373,11 @@ private static string SerializeEventDetail(T detail, JsonTypeInfo jsonType { Argument.ThrowIfNull(jsonTypeInfo); + // The writer's default encoder escapes non-ASCII characters, which response headers do not allow, + // in string values and dictionary keys even if the jsonTypeInfo options use a relaxed encoder. + // Property names and enum names are written as escaped by the encoder of those options. var buffer = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true })) + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true })) JsonSerializer.Serialize(writer, detail, jsonTypeInfo); return Encoding.UTF8.GetString(buffer.WrittenSpan); diff --git a/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs b/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs index 8066698..7c1ba50 100644 --- a/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Options; using Ramstack.HtmxToolkit.Configuration; -using Ramstack.HtmxToolkit.Serialization; namespace Ramstack.HtmxToolkit.Internal; @@ -151,7 +150,9 @@ private void SetHeader(string name, EventList events) { var buffer = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true })) + // The default encoder escapes non-ASCII characters in event names, + // which response headers do not allow. + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true })) { writer.WriteStartObject(); diff --git a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs index 21e2bc4..e2a0401 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs @@ -5,25 +5,14 @@ namespace Ramstack.HtmxToolkit.Serialization; /// /// Provides source-generated JSON serialization metadata for HX-Location options. /// +/// +/// The context uses the default encoder, which escapes non-ASCII characters, +/// because response headers allow only ASCII characters. +/// [JsonSourceGenerationOptions( WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, GenerationMode = JsonSourceGenerationMode.Default)] [JsonSerializable(typeof(HtmxLocationOptionsJsonView))] -internal partial class HtmxLocationOptionsJsonSerializerContext : JsonSerializerContext -{ - /// - /// Initializes the default serializer context with HTML-safe Unicode encoding. - /// - static HtmxLocationOptionsJsonSerializerContext() - { - JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); - - #if NET8_0_OR_GREATER - Default = new HtmxLocationOptionsJsonSerializerContext(s_defaultOptions); - #else - s_defaultContext = new HtmxLocationOptionsJsonSerializerContext(s_defaultOptions); - #endif - } -} +internal partial class HtmxLocationOptionsJsonSerializerContext : JsonSerializerContext; diff --git a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs index b740c2f..6e973da 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs @@ -11,22 +11,21 @@ namespace Ramstack.HtmxToolkit.Serialization; internal static class JsonOptions { /// - /// The shared encoder instance exposed by . + /// The shared encoder that preserves characters from all Unicode ranges while escaping + /// JavaScript- and HTML-sensitive characters. /// private static readonly JavaScriptEncoder s_encoder = JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All)); - /// - /// Gets the shared encoder that preserves characters from all Unicode ranges while escaping - /// JavaScript- and HTML-sensitive characters. - /// - public static JavaScriptEncoder Encoder => s_encoder; - /// /// Configures serializer options to preserve Unicode characters while escaping /// JavaScript- and HTML-sensitive characters. /// /// The serializer options to configure. + /// + /// Use it only for JSON rendered into HTML attributes. Response headers allow + /// only ASCII characters and must use the default encoder. + /// public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => options.Encoder = s_encoder; @@ -35,9 +34,12 @@ public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => /// for property names and dictionary keys and omit properties /// whose values are . /// + /// + /// The options use the default encoder, which escapes non-ASCII characters, + /// so that the resulting JSON can be written into a response header. + /// public static readonly JsonSerializerOptions CamelCase = new() { - Encoder = s_encoder, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index c679cdc..7453d2e 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -1,3 +1,5 @@ +using System.Text.Encodings.Web; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; @@ -142,6 +144,26 @@ public void Location_WithOptions_SerializesValuesAndHeaders() Assert.That(json["headers"].GetProperty("X-Test").GetString(), Is.EqualTo("abc")); } + [Test] + public void Location_WithNonAsciiOptions_WritesAsciiHeader() + { + // Kestrel rejects non-ASCII characters in response header values. + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions + { + Values = new Dictionary + { + ["имя"] = "Привет" + } + })); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(header.All(char.IsAscii), Is.True); + Assert.That(json["values"].GetProperty("имя").GetString(), Is.EqualTo("Привет")); + } + [Test] public void Location_WithOptions_SerializesHistoryAndOutOfBandOptions() { @@ -455,6 +477,38 @@ public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompact }); } + [Test] + public void TriggerEvent_WithNonAsciiDetail_EscapesNonAsciiCharacters() + { + // Kestrel rejects non-ASCII characters in response header values. + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("saved", new { Name = "Привет" })); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"saved\":{\"name\":\"\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\"}}")); + } + + [Test] + public void TriggerEvent_WithRelaxedEscapingJsonTypeInfo_EscapesNonAsciiCharacters() + { + // Minimal API JsonOptions use UnsafeRelaxedJsonEscaping, + // so metadata taken from them must not let non-ASCII characters into the header. + var context = TestHelper.CreateHtmxRequestContext(); + var options = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var info = new TriggerEventJsonSerializerContext(options).TriggerEventDetail; + context.Response.Htmx(r => r.TriggerEvent("saved", new TriggerEventDetail { Text = "Привет" }, info)); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"saved\":{\"text\":\"\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\"}}")); + } + [Test] public void TriggerEvent_NullObjectDetail_SerializesAsNullValue() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs index 23dc6f8..0eede68 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs @@ -263,6 +263,34 @@ public void AddEvent_PreservesEventNameCase_InReplayedEvents() Is.EqualTo("{\"rs:event\":[{\"key\":\"ShowMessage\",\"value\":1},{\"key\":\"ShowMessage\",\"value\":2}]}")); } + [Test] + public void AddEvent_EscapesNonAsciiEventName() + { + // Kestrel rejects non-ASCII characters in response header values. + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvent(HtmxTriggerTiming.Receive, "сохранено", "1"); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"\\u0441\\u043E\\u0445\\u0440\\u0430\\u043D\\u0435\\u043D\\u043E\":1}")); + } + + [Test] + public void AddEvent_EscapesNonAsciiEventName_InReplayedEvents() + { + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvent(HtmxTriggerTiming.Receive, "ок", "1"); + pending.AddEvent(HtmxTriggerTiming.Receive, "ок", "2"); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"rs:event\":[{\"key\":\"\\u043E\\u043A\",\"value\":1},{\"key\":\"\\u043E\\u043A\",\"value\":2}]}")); + } + [Test] public void AddEvent_WritesEventNamesThatDifferOnlyInCaseSeparately() { From 6d084fce38f6b748604c0fa03b8fb7b0b520e8f6 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 28 Oct 2026 15:30:28 +0500 Subject: [PATCH 2/3] http: clarify how header JSON escapes non-ASCII characters --- docs/articles/migrate-to-v2.md | 3 ++- docs/articles/responses.md | 7 +++++++ src/Ramstack.HtmxToolkit/HtmxResponse.cs | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index cb1b04f..aaa56ef 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -466,7 +466,8 @@ These changes affect fewer applications: - `HtmxHeaderTagHelper.Headers` changed from `Dictionary` to `IDictionary`, and header names are compared without case. - `HtmxScrollBehavior` gained `Instant`. -- Generated JSON in attributes and headers is encoded differently. Update snapshot tests that compare it as text. +- Generated JSON in attributes keeps non-ASCII characters, such as Cyrillic, instead of escaping them as `\uXXXX`. + Update snapshot tests that compare it as text. ## New in 2.0 diff --git a/docs/articles/responses.md b/docs/articles/responses.md index 3684091..c97e3db 100644 --- a/docs/articles/responses.md +++ b/docs/articles/responses.md @@ -252,6 +252,13 @@ Response.Htmx( The JSON naming policy keeps the payload the same, so the JavaScript listener above still reads `event.detail.id`. +Response headers allow only ASCII characters. Other characters in string values and dictionary keys +are escaped as `\uXXXX`, but property names and enum names are written as the encoder of the metadata +options escaped them. `AppJsonContext.Default` uses the default encoder, which escapes these names as well. +If you take the metadata from options with a relaxed encoder, such as Minimal API `JsonOptions`, +which use `JavaScriptEncoder.UnsafeRelaxedJsonEscaping`, keep these names ASCII-only; +otherwise, the server rejects the header. + ## Configure declaratively in MVC `HtmxResponseAttribute` is convenient when response behavior is constant: diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 1d1825a..634e62b 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -320,6 +320,14 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob /// Like any other response header, an event cannot be added after the response has started; /// the method then throws . /// + /// + /// Response headers allow only ASCII characters. Other characters in string values and dictionary keys + /// are escaped, but property names and enum names are written as escaped by the + /// of the options that + /// belongs to. With a relaxed encoder, such as + /// , + /// these names must contain only ASCII characters. + /// /// public HtmxResponse TriggerEvent(string eventName, T detail, JsonTypeInfo jsonTypeInfo, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => TriggerEventCore(this, eventName, detail, jsonTypeInfo, timing); From 51b3b8dfb0224050702874901db20cc45ef65065 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 28 Oct 2026 16:18:10 +0500 Subject: [PATCH 3/3] http: reorder JsonOptions members --- .../Serialization/JsonOptions.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs index 6e973da..07043bf 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs @@ -17,18 +17,6 @@ internal static class JsonOptions private static readonly JavaScriptEncoder s_encoder = JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All)); - /// - /// Configures serializer options to preserve Unicode characters while escaping - /// JavaScript- and HTML-sensitive characters. - /// - /// The serializer options to configure. - /// - /// Use it only for JSON rendered into HTML attributes. Response headers allow - /// only ASCII characters and must use the default encoder. - /// - public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => - options.Encoder = s_encoder; - /// /// The shared JSON serializer options that use /// for property names and dictionary keys and omit properties @@ -45,4 +33,16 @@ public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; + + /// + /// Configures serializer options to preserve Unicode characters while escaping + /// JavaScript- and HTML-sensitive characters. + /// + /// The serializer options to configure. + /// + /// Use it only for JSON rendered into HTML attributes. Response headers allow + /// only ASCII characters and must use the default encoder. + /// + public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => + options.Encoder = s_encoder; }