From a930baab7c88f2c8ab5c2bbdae917ee071c3c7e0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:42:44 +1200 Subject: [PATCH] [captive_portal] Escape SSID when building config JSON (#17872) --- .../captive_portal/captive_portal.cpp | 11 +- .../components/captive_portal/json_escape.h | 85 ++++++++++++++ tests/components/captive_portal/__init__.py | 23 ++++ .../captive_portal/json_escape_test.cpp | 107 ++++++++++++++++++ 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 esphome/components/captive_portal/json_escape.h create mode 100644 tests/components/captive_portal/__init__.py create mode 100644 tests/components/captive_portal/json_escape_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 228fdf7934..e6a63b8275 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" +#include "json_escape.h" namespace esphome::captive_portal { @@ -24,6 +25,9 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif + // An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most + // 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result. + char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1]; { // Invariant: only bounded in-memory work under the lock; the network send // happens later in request->send() @@ -32,18 +36,17 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { if (scan.get_is_hidden()) continue; - // Assumes no " in ssid, possible unicode issues? + json_escape_into_buffer(escaped_ssid, scan.get_ssid()); #ifdef USE_ESP8266 stream->print(ESPHOME_F(",{\"ssid\":\"")); - stream->print(scan.get_ssid().c_str()); + stream->print(escaped_ssid); stream->print(ESPHOME_F("\",\"rssi\":")); stream->print(scan.get_rssi()); stream->print(ESPHOME_F(",\"lock\":")); stream->print(scan.get_with_auth()); stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), - scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth()); #endif } } diff --git a/esphome/components/captive_portal/json_escape.h b/esphome/components/captive_portal/json_escape.h new file mode 100644 index 0000000000..0b3c71cd74 --- /dev/null +++ b/esphome/components/captive_portal/json_escape.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::captive_portal { + +/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence). +static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6; + +/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal. +/// +/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and +/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is +/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the +/// call can be used directly as an argument. +/// +/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for +/// the null terminator. +inline const char *json_escape_into_buffer(std::span buf, StringRef value) { + if (buf.empty()) + return ""; + // Reserve one byte for the null terminator. + const size_t limit = buf.size() - 1; + size_t pos = 0; + for (char ch : value) { + auto c = static_cast(ch); + // Every short form is a backslash followed by a single character, so only that character is needed here. Keeping + // it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266. + char escape = '\0'; + switch (c) { + case '"': + escape = '"'; + break; + case '\\': + escape = '\\'; + break; + case '\n': + escape = 'n'; + break; + case '\r': + escape = 'r'; + break; + case '\t': + escape = 't'; + break; + case '\b': + escape = 'b'; + break; + case '\f': + escape = 'f'; + break; + default: + break; + } + if (escape != '\0') { + if (pos + 2 > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = escape; + } else if (c < 0x20) { + // Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so + // the two high hex digits are always zero. + if (pos + JSON_ESCAPE_MAX_EXPANSION > limit) + break; + buf[pos++] = '\\'; + buf[pos++] = 'u'; + buf[pos++] = '0'; + buf[pos++] = '0'; + buf[pos++] = format_hex_char(static_cast(c >> 4)); + buf[pos++] = format_hex_char(static_cast(c & 0x0F)); + } else { + if (pos + 1 > limit) + break; + buf[pos++] = static_cast(c); + } + } + buf[pos] = '\0'; + return buf.data(); +} + +} // namespace esphome::captive_portal diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py new file mode 100644 index 0000000000..b13c81912c --- /dev/null +++ b/tests/components/captive_portal/__init__.py @@ -0,0 +1,23 @@ +"""Test-manifest overrides for the captive_portal C++ unit tests. + +``json_escape`` lives in a standalone, dependency-free header +(``esphome/components/captive_portal/json_escape.h``). The rest of the +captive_portal component and its auto-loaded dependencies (``web_server_base``, +``ota.web_server``) do not build for the ``host`` platform that the C++ unit +test harness targets. Strip those away and replace the real schema -- which is +restricted to non-host platforms via ``cv.only_on`` and requires a +``web_server_base`` instance via ``use_id`` -- with an empty one so the host +test config validates. ``to_code`` stays suppressed (the default), so +``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an +empty translation unit; only ``json_escape.h`` is exercised by the test. +""" + +import esphome.config_validation as cv +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.auto_load = [] + manifest.dependencies = [] + manifest.config_schema = cv.Schema({}) + manifest.final_validate_schema = None diff --git a/tests/components/captive_portal/json_escape_test.cpp b/tests/components/captive_portal/json_escape_test.cpp new file mode 100644 index 0000000000..98b5ce4ff7 --- /dev/null +++ b/tests/components/captive_portal/json_escape_test.cpp @@ -0,0 +1,107 @@ +#include + +#include + +#include "esphome/components/captive_portal/json_escape.h" + +namespace esphome::captive_portal::testing { + +namespace { + +// Large enough that none of the inputs below are ever dropped. +constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1; + +// Escape into a stack buffer and return the result as a string so the expectations stay readable. +std::string escape(const std::string &value) { + char buf[TEST_BUFFER_SIZE]; + return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size())); +} + +} // namespace + +// Plain ASCII with no special characters is passed through unchanged. +TEST(CaptivePortalJsonEscape, PlainStringUnchanged) { + EXPECT_EQ(escape("MyNetwork"), "MyNetwork"); + EXPECT_EQ(escape(""), ""); +} + +// A double quote is escaped so it does not terminate the surrounding JSON string. +TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) { + EXPECT_EQ(escape("a\"b"), "a\\\"b"); + // A double quote followed by other characters stays inside the JSON string. + EXPECT_EQ(escape("\">end"), "\\\">end"); +} + +// A backslash is doubled so it does not start an escape sequence in the output. +TEST(CaptivePortalJsonEscape, EscapesBackslash) { + EXPECT_EQ(escape("a\\b"), "a\\\\b"); + // A trailing backslash must not escape the closing quote of the JSON string. + EXPECT_EQ(escape("net\\"), "net\\\\"); +} + +// The control characters with short JSON forms use those forms. +TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { + EXPECT_EQ(escape("\n"), "\\n"); + EXPECT_EQ(escape("\r"), "\\r"); + EXPECT_EQ(escape("\t"), "\\t"); + EXPECT_EQ(escape("\b"), "\\b"); + EXPECT_EQ(escape("\f"), "\\f"); +} + +// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex. +TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { + EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000"); + EXPECT_EQ(escape("\x01"), "\\u0001"); + EXPECT_EQ(escape("\x10"), "\\u0010"); + EXPECT_EQ(escape("\x1f"), "\\u001f"); + // 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper. + EXPECT_EQ(escape("\x7f"), "\x7f"); +} + +// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim. +TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { + // "café" in UTF-8 (é == 0xC3 0xA9). + EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9"); + // Emoji (📶, 4-byte UTF-8) survives unchanged. + EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6"); +} + +// A mix of special and normal characters is escaped in place without disturbing the rest. +TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); } + +// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly. +TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { + constexpr size_t input_len = 8; + char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1]; + const std::string input(input_len, '\x01'); + std::string expected; + for (size_t i = 0; i < input_len; i++) + expected += "\\u0001"; + EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected); +} + +// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null +// terminated. +TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { + // Room for one \u00XX sequence plus the null terminator, but two are requested. + char buf[JSON_ESCAPE_MAX_EXPANSION + 1]; + const std::string input(2, '\x01'); + const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())); + EXPECT_EQ(result, "\\u0001"); + EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0'); +} + +// Plain characters are truncated at the buffer size, leaving room for the null terminator. +TEST(CaptivePortalJsonEscape, TruncatesPlainInput) { + char buf[5]; + const std::string input(20, 'a'); + EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa"); +} + +// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing. +TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) { + const std::string input("test"); + EXPECT_STREQ(json_escape_into_buffer(std::span(), StringRef(input.c_str(), input.size())), ""); +} + +} // namespace esphome::captive_portal::testing