Constrain terminal layout and externalize TLS memory

Improve CSP and favicon handling while preventing terminal resize loops.
Configure mbedTLS allocations in external PSRAM and document its
security implications.
This commit is contained in:
2026-08-24 20:52:10 +02:00
parent 5f7ea5b79d
commit c018bfe361
3 changed files with 48 additions and 19 deletions
+5 -3
View File
@@ -288,7 +288,9 @@ GET /assets/addon-fit.js
GET /assets/app.js GET /assets/app.js
``` ```
The page, status API, assets, and ticket endpoint require HTTP Basic authentication over TLS. `/` is now a responsive xterm.js serial workspace; `/api/status` returns JSON containing uptime plus non-secret Wi-Fi, serial-service, broker, native-USB, HTTPS, and WebSocket state/counters. xterm.js and FitAddon are pinned, vendored, compressed, and served by the ESP32 itself, so the terminal works while connected only to the fallback AP and never depends on a CDN. The page, status API, assets, and ticket endpoint require HTTP Basic authentication over TLS. `/` is now a responsive xterm.js serial workspace; `/api/status` returns JSON containing uptime plus non-secret Wi-Fi, serial-service, broker, native-USB, HTTPS, and WebSocket state/counters. xterm.js and FitAddon are pinned, vendored, compressed, and served by the ESP32 itself, so the terminal works while connected only to the fallback AP and never depends on a CDN. The terminal is constrained to the browser viewport, and fitting only resizes xterm when the host dimensions and resulting row/column count actually change; terminal rendering therefore cannot create a self-amplifying page-height loop.
The document CSP permits scripts only from the device itself. xterm.js requires inline styles for its DOM renderer, but inline scripts remain prohibited. A data-URL empty favicon avoids an unnecessary authenticated `/favicon.ico` request. A CSP warning naming a UUID or browser extension despite no inline script in the served document is browser-injected content and is intentionally not hash-whitelisted by the firmware.
On first boot, the device generates and persists: On first boot, the device generates and persists:
@@ -368,9 +370,9 @@ For end-to-end validation:
Vendored browser sources, versions, hashes/provenance, deterministic gzip artifacts, and MIT license notices are recorded under [`web_assets/`](web_assets/SOURCES.md). Third-party code remains under its upstream license; project firmware code remains GPL-3.0-only. Vendored browser sources, versions, hashes/provenance, deterministic gzip artifacts, and MIT license notices are recorded under [`web_assets/`](web_assets/SOURCES.md). Third-party code remains under its upstream license; project firmware code remains GPL-3.0-only.
HTTPS permits up to six simultaneous client sockets: two bounded persistent WebSocket terminals plus parallel browser asset, ticket, and status requests. ESP-IDF documents approximately 40 KiB per active TLS socket, so this is a concurrency ceiling rather than preallocated per-socket memory. WebSocket serial sessions themselves remain fixed at two. Basic authentication is acceptable here only because plaintext HTTP is disabled. It is an initial administration mechanism, not the final authorization design. HTTPS permits up to six simultaneous client sockets: two bounded persistent WebSocket terminals plus parallel browser asset, ticket, and status requests. ESP-IDF documents approximately 40 KiB per active TLS socket, so this is a concurrency ceiling rather than preallocated per-socket memory. WebSocket serial sessions themselves remain fixed at two. To preserve internal DRAM during concurrent handshakes, the firmware configures ESP-IDF's mbedTLS allocator for the board's external PSRAM. Basic authentication is acceptable here only because plaintext HTTP is disabled. It is an initial administration mechanism, not the final authorization design.
**Current security limitation:** the web password and ECDSA private key are stored as plaintext in the application-owned `web_sec/material` NVS blob, just as Wi-Fi credentials are currently plaintext in `wifi_app/config`. The reserved `nvs_key` partition does not activate NVS encryption. ESP-IDF 5.5 also keeps an internal heap copy of the active TLS private key and does not guarantee zeroization when that allocation is freed. Do not treat the current firmware as resistant to physical flash or RAM extraction; NVS encryption, flash encryption, secure boot, protected OTA, secret-aware core-dump handling, and framework-level key zeroization belong to the later hardening phase. **Current security limitation:** the web password and ECDSA private key are stored as plaintext in the application-owned `web_sec/material` NVS blob, just as Wi-Fi credentials are currently plaintext in `wifi_app/config`. The reserved `nvs_key` partition does not activate NVS encryption. ESP-IDF 5.5 also keeps active mbedTLS allocations, including TLS key/session material, in external PSRAM under this memory configuration and does not guarantee zeroization when those allocations are freed. PSRAM encryption is not enabled in the current firmware. Do not treat the current firmware as resistant to physical flash or RAM extraction; NVS encryption, flash/PSRAM encryption, secure boot, protected OTA, secret-aware core-dump handling, and framework-level key zeroization belong to the later hardening phase.
### Phase 0 diagnostics ### Phase 0 diagnostics
+5
View File
@@ -6,6 +6,11 @@ CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_BOOT_INIT=y CONFIG_SPIRAM_BOOT_INIT=y
CONFIG_SPIRAM_USE_CAPS_ALLOC=y CONFIG_SPIRAM_USE_CAPS_ALLOC=y
# Keep concurrent HTTPS handshakes from exhausting scarce internal DRAM.
# Active TLS material remains unencrypted in PSRAM until the hardening phase.
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
# Native USB OTG presents one CDC-ACM interface on the ESP32-S3 USB port. # Native USB OTG presents one CDC-ACM interface on the ESP32-S3 USB port.
CONFIG_TINYUSB_CDC_ENABLED=y CONFIG_TINYUSB_CDC_ENABLED=y
CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_CDC_COUNT=1
+38 -16
View File
@@ -25,13 +25,14 @@ static const char s_index_html[] =
"--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;" "--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;"
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n" "--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
"*{box-sizing:border-box}\n" "*{box-sizing:border-box}\n"
"html,body{height:100%;margin:0}\n" "html,body{height:100%;margin:0;overflow:hidden}\n"
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);" "body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont," "color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
"\"Segoe UI\",sans-serif}\n" "\"Segoe UI\",sans-serif}\n"
"button{font:inherit}\n" "button{font:inherit}\n"
".page{min-height:100%;max-width:1440px;margin:auto;padding:clamp(14px,2.5vw,32px);" ".page{height:100%;height:100dvh;min-height:0;max-width:1440px;margin:auto;"
"display:grid;grid-template-rows:auto auto minmax(360px,1fr);gap:16px}\n" "padding:clamp(14px,2.5vw,32px);display:grid;"
"grid-template-rows:auto auto minmax(0,1fr);gap:16px}\n"
".topbar{display:flex;align-items:center;justify-content:space-between;gap:16px}\n" ".topbar{display:flex;align-items:center;justify-content:space-between;gap:16px}\n"
".brand{display:flex;align-items:center;gap:12px;min-width:0}\n" ".brand{display:flex;align-items:center;gap:12px;min-width:0}\n"
".logo{width:42px;height:42px;border:1px solid #347ba5;border-radius:12px;" ".logo{width:42px;height:42px;border:1px solid #347ba5;border-radius:12px;"
@@ -69,12 +70,13 @@ static const char s_index_html[] =
".input-state{margin:0;color:var(--warn);font-size:13px}\n" ".input-state{margin:0;color:var(--warn);font-size:13px}\n"
".input-state[data-enabled=true]{color:var(--good)}\n" ".input-state[data-enabled=true]{color:var(--good)}\n"
".connection-detail{margin:0;color:var(--muted);font-size:12px;min-height:1.45em}\n" ".connection-detail{margin:0;color:var(--muted);font-size:12px;min-height:1.45em}\n"
".terminal-panel{min-height:0;padding:10px;display:flex;flex-direction:column;overflow:hidden}\n" ".terminal-panel{min-width:0;min-height:0;padding:10px;display:flex;flex-direction:column;overflow:hidden}\n"
".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;" ".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;"
"padding:1px 5px 9px;color:var(--muted);font-size:12px}\n" "padding:1px 5px 9px;color:var(--muted);font-size:12px}\n"
".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n" ".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
"#terminal{flex:1;min-height:0;border-radius:9px;overflow:hidden;background:#080c12;padding:8px}\n" "#terminal{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
"#terminal .xterm{height:100%}\n" "background:#080c12;padding:8px}\n"
"#terminal .xterm{width:100%;height:100%}\n"
"#terminal .xterm-viewport{border-radius:7px}\n" "#terminal .xterm-viewport{border-radius:7px}\n"
"@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}" "@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n" ".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n"
@@ -82,8 +84,9 @@ static const char s_index_html[] =
".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}" ".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}"
".status-item{padding:9px}.controls{padding:12px}.terminal-panel{padding:7px}" ".status-item{padding:9px}.controls{padding:12px}.terminal-panel{padding:7px}"
".button-row{display:grid;grid-template-columns:1fr 1fr;width:100%}" ".button-row{display:grid;grid-template-columns:1fr 1fr;width:100%}"
".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(420px,1fr)}}\n" ".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(0,1fr)}}\n"
"</style>\n" "</style>\n"
"<link rel=\"icon\" href=\"data:,\">\n"
"<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n" "<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n"
"<script defer src=\"/assets/xterm.js\"></script>\n" "<script defer src=\"/assets/xterm.js\"></script>\n"
"<script defer src=\"/assets/addon-fit.js\"></script>\n" "<script defer src=\"/assets/addon-fit.js\"></script>\n"
@@ -173,6 +176,8 @@ static const char s_app_js[] =
"let writerId = 0;\n" "let writerId = 0;\n"
"let unloading = false;\n" "let unloading = false;\n"
"let fitFrame = 0;\n" "let fitFrame = 0;\n"
"let lastFitWidth = 0;\n"
"let lastFitHeight = 0;\n"
"let statusInFlight = false;\n" "let statusInFlight = false;\n"
"let statusTimer = null;\n" "let statusTimer = null;\n"
"const setBadge = (target, text, tone) => {\n" "const setBadge = (target, text, tone) => {\n"
@@ -329,14 +334,27 @@ static const char s_app_js[] =
" reconnectDelay = 1000;\n" " reconnectDelay = 1000;\n"
" connect();\n" " connect();\n"
"});\n" "});\n"
"const scheduleFit = () => {\n" "const fitTerminal = () => {\n"
" if (fitFrame !== 0) return;\n" " fitFrame = 0;\n"
" fitFrame = window.requestAnimationFrame(() => {\n" " const bounds = terminalHost.getBoundingClientRect();\n"
" fitFrame = 0;\n" " const width = Math.floor(bounds.width);\n"
" try { fitAddon.fit(); } catch (_) {}\n" " const height = Math.floor(bounds.height);\n"
" });\n" " if (width < 1 || height < 1 || (width === lastFitWidth && height === lastFitHeight)) return;\n"
" lastFitWidth = width;\n"
" lastFitHeight = height;\n"
" try {\n"
" const dimensions = fitAddon.proposeDimensions();\n"
" if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n"
" (dimensions.cols !== terminal.cols || dimensions.rows !== terminal.rows)) {\n"
" terminal.resize(dimensions.cols, dimensions.rows);\n"
" }\n"
" } catch (_) {}\n"
"};\n" "};\n"
"if ('ResizeObserver' in window) new ResizeObserver(scheduleFit).observe(terminalHost);\n" "const scheduleFit = () => {\n"
" if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n"
"};\n"
"const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n"
"if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n"
"window.addEventListener('resize', scheduleFit);\n" "window.addEventListener('resize', scheduleFit);\n"
"const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n" "const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n"
"const updateStatus = (status) => {\n" "const updateStatus = (status) => {\n"
@@ -397,6 +415,9 @@ static const char s_app_js[] =
" ++connectionGeneration;\n" " ++connectionGeneration;\n"
" clearReconnectTimer();\n" " clearReconnectTimer();\n"
" if (statusTimer !== null) window.clearInterval(statusTimer);\n" " if (statusTimer !== null) window.clearInterval(statusTimer);\n"
" if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n"
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
" window.removeEventListener('resize', scheduleFit);\n"
" if (ticketAbort !== null) ticketAbort.abort();\n" " if (ticketAbort !== null) ticketAbort.abort();\n"
" if (socket !== null) socket.close();\n" " if (socket !== null) socket.close();\n"
" socket = null;\n" " socket = null;\n"
@@ -484,8 +505,9 @@ static esp_err_t set_response_headers(httpd_req_t *request,
if (result == ESP_OK && response->content_security_policy) { if (result == ESP_OK && response->content_security_policy) {
result = httpd_resp_set_hdr( result = httpd_resp_set_hdr(
request, "Content-Security-Policy", request, "Content-Security-Policy",
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " "default-src 'none'; script-src 'self'; script-src-elem 'self'; "
"connect-src 'self'; base-uri 'none'; form-action 'none'; " "style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; "
"base-uri 'none'; form-action 'none'; "
"frame-ancestors 'none'"); "frame-ancestors 'none'");
} }
return result; return result;