From 33a6acb0bf73743ad3068628434619fc46a442ad Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 11:41:00 +0200 Subject: [PATCH] compat: stop mangling refusal messages with HTML escapes The server's 426 body reached the user as "needs \u003e= 0.2.0, \u003c 1.0.0": Go escapes <, > and & by default for JSON destined for a page, which this is not. Disabled at the encoder. The client now parses the error field rather than pattern-matching it, so it survives whatever a future encoder decides to escape. Co-Authored-By: Claude Fable 5 --- .../app/echo_lot/protocol/ControlClient.kt | 19 ++++++++++++------- server/internal/control/control.go | 7 ++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt index b50b96c..80ae18b 100644 --- a/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt +++ b/echolot-app/core-protocol/src/main/kotlin/app/echo_lot/protocol/ControlClient.kt @@ -4,6 +4,8 @@ package app.echo_lot.protocol import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import java.net.URL import javax.net.ssl.HttpsURLConnection @@ -59,13 +61,16 @@ class ControlClient( if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200)) } - /** Pulls the "error" string out of a JSON body without pulling in a parser for one field. */ - private fun extractError(body: String): String? = - Regex(""""error"\s*:\s*"((?:[^"\\]|\\.)*)"""").find(body) - ?.groupValues?.get(1) - ?.replace("\\\"", "\"") - ?.replace("\\n", "\n") - ?.replace("\\\\", "\\") + /** + * Pulls the "error" string out of a JSON body. + * + * Parsed rather than pattern-matched: an encoder may legitimately escape characters in the + * message (Go escapes ">" by default), and a regex hands the user "needs \u003e= 0.2.0". + * The parser knows how to undo every escape; a regex would have to be taught each one. + */ + private fun extractError(body: String): String? = runCatching { + json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content + }.getOrNull() /** * Reads the response body, and turns a 426 into [VersionRefused] first. diff --git a/server/internal/control/control.go b/server/internal/control/control.go index c2d8743..931b3b4 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -430,7 +430,12 @@ func bearer(r *http.Request) string { func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) + enc := json.NewEncoder(w) + // Go escapes <, > and & by default, for JSON embedded in HTML. This is an API, and the + // escaping is actively harmful here: a refusal message reading "needs >= 0.2.0" is what + // the user ends up seeing. Nothing we emit is ever interpolated into a page. + enc.SetEscapeHTML(false) + _ = enc.Encode(v) } // enroll redeems a single-use enrollment token for a device credential (ยง2.1).