From 840196237672156d69a5ab628da0736d40c393cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:09:55 +0000 Subject: [PATCH] portal: never send a submitted form from HTTPS to plain HTTP on 307/308 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DF6UHfRUSGEc8s57ZroLLE --- SECURITY.md | 2 +- main/portal_client.cpp | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index de0b9c7..99544e3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -212,7 +212,7 @@ Scope): sign-in pages are now an allowed destination, only while the user runs ` | Change | Security review | |---|---| -| New outbound traffic: a plain-HTTP request to `connectivitycheck.gstatic.com/generate_204` (if that name doesn't resolve: the network's own gateway, `http:///`), then the pages, links and form targets the user picks | Only on the user's request, never automatically (CI guard). Plain HTTP is used for the probe alone, because a portal can only intercept unencrypted traffic; a 204 answer means the internet works. Every HTTPS page is verified with the ESP-IDF CA bundle: a portal with a self-signed certificate fails to load rather than being trusted. Redirects are followed by hand, at most 8, only to http(s) URLs without `user@` parts (IPv4 / DNS names only) | +| New outbound traffic: a plain-HTTP request to `connectivitycheck.gstatic.com/generate_204` (if that name doesn't resolve: the network's own gateway, `http:///`), then the pages, links and form targets the user picks | Only on the user's request, never automatically (CI guard). Plain HTTP is used for the probe alone, because a portal can only intercept unencrypted traffic; a 204 answer means the internet works. Every HTTPS page is verified with the ESP-IDF CA bundle: a portal with a self-signed certificate fails to load rather than being trusted. Redirects are followed by hand, at most 8, only to http(s) URLs (a 307/308 redirect never carries a submitted form from HTTPS to plain HTTP) without `user@` parts (IPv4 / DNS names only) | | HTML parser for untrusted pages (`main/portal.cpp`, host-tested with hostile and truncated input under ASan/UBSan) | No scripts run; only a literal `location = "..."` or meta refresh is followed, and only on a page with nothing to fill in. Body capped at 192 KB in PSRAM (zeroed before it is freed); at most 8 forms, 60 fields, 40 options per list, 12 links, 1500 characters of text, 512-byte values, 1 KB URLs. Text reaches the screen as printable ASCII (no control or escape characters). `javascript:`, `data:` and other schemes are dropped | | Cookies | Kept in RAM for the sign-in only, at most 30 of 1 KB; a cookie is only accepted for the host that set it or its parent domain (not a bare top-level domain), `Secure` cookies only go over HTTPS, and the jar is wiped when the sign-in ends | | A worker task (10 KB stack) per page load | Created per request and deleted when done, like the ChatGPT worker; it touches the menu only under the display lock and drops its result if the user left the sign-in (generation counter) | diff --git a/main/portal_client.cpp b/main/portal_client.cpp index 550afa0..916a471 100644 --- a/main/portal_client.cpp +++ b/main/portal_client.cpp @@ -186,6 +186,12 @@ esp_err_t fetch(const Request& request, Cookies& cookies, Result& out) wipe(req.body); // 301/302/303: the next request is a plain GET req.post = false; } + Url target; + if (req.post && url.https && parse_url(next, target) && !target.https) { + out.error = "The page tried to send what you typed over an unencrypted connection."; // Never https -> http with a body + wipe(req); + return ESP_FAIL; + } req.referer = url.str(); req.url = next; continue;