Add Standalone Login Page Renderer

Add a hash-bound, no-store login document with focused C and Node host
tests. Keep rendering inert until the 8D.3 authentication cutover.
This commit is contained in:
2026-09-05 18:45:12 +02:00
parent 00f226dc59
commit 4435a7fddd
9 changed files with 542 additions and 4 deletions
+1
View File
@@ -71,6 +71,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Security constraints: Basic-auth cache hits still revalidate principal currentness; the browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect until Connect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
- Session-store boundary: admitted HTTPS start initializes four static records; failed start/accepted stop disables and wipes them. 8D.2 binds tickets/slots to non-reused session IDs (zero only for Basic); transport-specific cleanup and account/global revocation invalidate store records before socket cleanup. No cookie route yet. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode.
- 8D.3 preparation: `src/web_auth_parse.{c,h}` provides inert, allocation-free origin/cookie/login-JSON parsing; no HTTP caller yet. Test with `python3 tests/web_auth_parse/run.py`. These helpers do not authenticate or replace HTTP header/method/CSRF policy.
- 8D.3 login rendering: `src/web_login_ui.{c,h}` contains a standalone no-store login document and hash-bound script; no registered route or live caller. `python3 tests/web_login_ui/run.py` checks production C rendering, CSP and Node DOM/fetch doubles. Existing `web_ui.c` and generated assets are unchanged.
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
## SSH
+12 -1
View File
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
## Development state
- **8D.3 inert login renderer completed (2026-09-05):** User requested continuation after the parser split. Added standalone `web_login_ui.{c,h}` plus production-renderer/Node tests; no live route or Basic-auth change. Build and focused suites pass; 8D.3/M1 remains incomplete. Remaining work is the atomic server/app cutover, then mandatory browser/target validation. See active task below.
- **8D.3 preparatory parser split (2026-09-05):** User requested continuation. Per the plan's 600800-line scope review, selected inert private request parsing before the larger atomic login/logout cutover. `src/web_auth_parse.{c,h}` and focused host tests added; no live HTTP callers or authentication changes. See active task below. 8D.3/M1 is **not complete**; Basic remains active. Prior 8D.2 user sign-off stands; numeric reserves remain open.
- **8D.2 validated by user sign-off (2026-09-05):** User explicitly closed 8D.2 after post-flash boot and full-client-mix samples. This supersedes older target-pending/in-progress statements below. Evidence and limits are in `docs/phase8d2_implementation.md`; unrecorded regression details are not claims of execution or blockers to the signed-off checkpoint. Numeric reserves remain open. Wait for a separate 8D.3 request.
@@ -58,7 +60,16 @@ These observations should be checked when touching the relevant area; they are n
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
- If HTTPD concurrency configuration changes, add locking around the boot-local Basic-authentication cache.
## Active Task - Phase 8D.3 Inert Request Parsing
## Active Task - Phase 8D.3 Inert Login Rendering
- **Scope:** Second permitted preparatory split after scope review of remaining challenge/throttle/HTTP-route/application work. Added only `src/web_login_ui.{c,h}`, CMake registration, `tests/web_login_ui/` and documentation. Standalone renderer has no live HTTP caller or URI registration; Basic cache/auth, existing app and serial protocol, capacities, task stacks and generated assets are unchanged. No upload, erase, commit or branch change.
- **Behavior when integrated:** No fetch on page load; explicit Sign in obtains challenge with `X-Login-Bootstrap: 1`, then POSTs JSON with CSRF. Same-origin credentials/mode, no-store fetch, redirect rejection, 512-byte response bound and UTF-8 field/body limits. Generic safe-text errors, bounded Retry-After display/manual backoff, no automatic credential retry, fixed success navigation to `/`. Inputs disabled while pending; password fields/references cleared best-effort, attempt aborted on every exit, 15-second deadline, pagehide/pageshow generation guards. No localStorage/cookie access or logging; JavaScript/browser memory cannot be securely wiped.
- **Policy/resources:** Standalone 7,387-byte HTML document plus terminator in its object; no protected asset dependencies or template interpolation. Five additional headers (no-store, nosniff, no-referrer, frame denial, matching SHA-256 CSP), below existing eight-header limit. Rendering itself allocates no module heap/session/task. Unused renderer leaves final linked RAM/flash **95,260 / 1,601,925 B**, unchanged versus 8D.2 and parser split. Future linked/read-only data, HTTPD send/TLS and runtime stack/heap costs remain cutover measurements; no runtime reserve approval implied.
- **Validation:** `python3 tests/web_login_ui/run.py` passes production-C NULL/header/send-failure checks, exact rendered CSP hash and eight Node VM behavior groups. `python3 tests/web_auth_parse/run.py` passes 268 cases; existing store and `--serial` host modes pass. Independent review caught/fixed unread error-body lifetime and re-entered password retention; regression tests cover abort signals/modeled streams and pending-input cleanup. Final `pio run` passed in **8.25 seconds**, unchanged sizes. `git diff --check` passed. No real browser/CSP enforcement/bfcache, HTTPD sockets, TLS or target exercise.
- **Next exact work:** Continue **8D.3 atomic live cutover**, not more login rendering or 8D.4. Reuse prepared parser/renderer/store/serial binding. Implement bounded challenge/throttle and complete cookie/session/CSRF/Origin policy, replace Basic for every app/asset/status/ticket/upgrade route together, add `/login`, challenge/login/session/logout routes, and integrate existing application's session validation/logout/401 recovery without changing explicit serial reconnect. Retain no-live-eviction ticket policy cutover, fail-closed initialization and account/session-specific revocation. Duplicate-header detection and two Set-Cookie behavior must be verified against actual IDF HTTPD, not assumed. Keep response header/route/body budgets explicit. Then M1 target/browser pause and resource evidence; no M2 work.
- **Remaining gates:** Full M1 checklist in plan/baseline applies only once live: both roles/fresh and former Basic profiles, direct IP/mDNS, challenge/session/capacity/throttle/Origin/CSRF errors, logout/account switch/expiry/reboot/revocation isolation, repeated serial cycles, UART0/USB/user+admin SSH and soak/cleanup memory. Numeric reserves remain pending; 8D.2 user sign-off stands.
## Previous Task - Phase 8D.3 Inert Request Parsing
- **Scope:** Private allocation-free helpers for canonical Host/Origin comparison, unique named 64-lowercase-hex cookie extraction, and strict login JSON. CMake registration plus `tests/web_auth_parse/`; no live route, UI, store, transport, task, socket, stack-size or generated-asset change. Split before coding because full challenge/throttle/routes/UI/tests cutover exceeds the work-unit guideline. No commit, upload or erase.
- **Contracts:** Exact bounded byte spans; canonical origin max 128 bytes after optional default-port normalization, ASCII DNS/IPv4 authorities only (IPv6 rejected), mandatory matching HTTPS Origin. Cookie header max 1024 bytes, strict unquoted values even for unrelated cookies. Login body max 512 bytes, exactly username/password strings, decoded limits 16/64 bytes; proper UTF-8/JSON escape/surrogate validation. Unknown/duplicate fields and NUL rejected. Database policy remains authoritative. Failures clear complete outputs; successful credentials and original body require caller wiping. No dynamic allocation or persistent state.
+5 -3
View File
@@ -1,6 +1,6 @@
# Phase 8D — Incremental web administration plan
Status: **8D.0 through 8D.2 validated by user sign-off on 2026-09-05. 8D.2 implemented, host-tested and build-verified. Reserve budgets remain pending. 8D.3 has an inert request-parser preparatory split; its live cutover and 8D.48D.22 remain planned.** See the [8D.0 baseline and M1 contract](phase8d_baseline.md); no browser authentication cutover is claimed.
Status: **8D.0 through 8D.2 validated by user sign-off on 2026-09-05. 8D.2 implemented, host-tested and build-verified. Reserve budgets remain pending. 8D.3 has inert request-parser and login-renderer preparatory splits; its live cutover and 8D.48D.22 remain planned.** See the [8D.0 baseline and M1 contract](phase8d_baseline.md); no browser authentication cutover is claimed.
This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist.
@@ -92,6 +92,8 @@ If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering
**Preparatory split (2026-09-05):** Scope review selected private request parsing first; the complete challenge/throttle/route/browser/test change exceeds the authored-line work-unit target. Added allocation-free `src/web_auth_parse.{c,h}` with no live HTTP callers: bounded canonical same-origin comparison, unique cookie extraction and strict UTF-8 login JSON decoding. `python3 tests/web_auth_parse/run.py` passes **268 cases** against production C; both existing session-store host modes pass. Final `pio run` passes in **7.50 seconds** and reports **95,260 B RAM / 1,601,925 B flash**, unchanged from 8D.2 because helpers are not live linked paths. No route/task/socket/stack-size/asset changes. No target/browser validation, runtime reserve approval or M1 completion is implied. Continue within **8D.3**, with the full atomic cutover still pending; see `docs/agent/current-state.md` for exact integration obligations.
**Login-renderer preparatory split (2026-09-05):** Added `src/web_login_ui.{c,h}` with no live caller/route, leaving Basic and existing serial UI unchanged. Standalone 7,387-byte HTML plus terminator has no protected asset dependencies, five security headers including no-store and exact script-hash CSP. Explicit-only challenge/login flow, bounded request/response handling, safe errors/manual backoff, disabled pending inputs, best-effort password/reference cleanup and generation-safe page-exit cancellation. Review fixes abort every attempt on exit (including unread error bodies) and clear re-entered passwords. `python3 tests/web_login_ui/run.py` passes production C rendering/failure checks, exact CSP hash and eight Node VM groups; parser and both session test modes also pass. Final `pio run` passes in **8.25 seconds**, unchanged **95,260 B RAM / 1,601,925 B flash**: unused renderer costs are not live-linked/runtime costs yet. No new task/socket/route/stack-size/module heap or generated-asset change. No real-browser/HTTPD/hardware validation or M1 completion. **Next is the atomic live 8D.3 cutover using both prepared pieces**, followed by the mandatory M1 target gate; no additional login-rendering split is needed.
## M2 — Reuse the admin shell, then expose it
### 8D.4 — Small transport-neutral console boundary
@@ -161,11 +163,11 @@ Update the roadmap and user/command documentation to distinguish completed featu
## Progress and next-request template
Progress: **8D.0 through 8D.2 validated by user sign-off; 8D.2 implemented / host-tested / build-verified. Reserve gates pending. 8D.3 inert parser preparation implemented; live cutover and 8D.48D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks.
Progress: **8D.0 through 8D.2 validated by user sign-off; 8D.2 implemented / host-tested / build-verified. Reserve gates pending. 8D.3 inert parser and login-renderer preparation implemented; live cutover and 8D.48D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks.
Suggested next request:
> Continue Phase 8D.3 after its inert request-parser split. Complete login/logout authentication atomically (or split inert login rendering first if needed), preserve 8D.2 sign-off and open numeric reserve gates, and stop for M1 target/browser validation before 8D.4.
> Continue Phase 8D.3 with the atomic live login/logout cutover; request parsing and standalone login rendering are prepared and host-tested. Preserve 8D.2 sign-off and open numeric reserve gates, and stop for M1 target/browser validation before 8D.4.
For later chunks:
+1
View File
@@ -36,6 +36,7 @@ idf_component_register(
"web_server.c"
"web_session_store.c"
"web_auth_parse.c"
"web_login_ui.c"
"web_console.c"
"wifi_config.c"
"wifi_manager.c"
+160
View File
@@ -0,0 +1,160 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_login_ui.h"
#include <stddef.h>
/* Authored standalone page; no dependency on protected or generated assets. */
static const char s_login_html[] =
"<!doctype html>\n<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
"<title>Sign in - ESP32 Serial Console</title>\n"
"<style>\n"
":root{color-scheme:dark;font:16px/1.5 system-ui,sans-serif;background:#090d14;color:#e8eef8}\n"
"*{box-sizing:border-box}body{margin:0;padding:2rem 1rem}main{max-width:28rem;margin:3vh auto;"
"padding:1.5rem;background:#111824;border:1px solid #29364a;border-radius:14px}\n"
"h1{font-size:1.5rem}label{display:block;margin-top:1rem}input,button{font:inherit;"
"width:100%;padding:.65rem;border:1px solid #91a0b5;border-radius:6px}"
"input{background:#090d14;color:inherit}button{margin-top:1.25rem;background:#55c2ff;"
"color:#090d14;cursor:pointer}button:disabled{opacity:.6;cursor:wait}"
"a{color:#55c2ff}:focus-visible{outline:3px solid #ffc857;outline-offset:3px}"
"#message{min-height:3em}small{display:block;color:#b6c2d4}\n"
"</style>\n</head>\n<body>\n<main>\n"
"<h1>ESP32 Serial Console</h1>\n"
"<p>Sign in to access the serial terminal.</p>\n"
"<form id=\"login\" method=\"post\" action=\"/api/login\">\n"
"<label for=\"username\">Username</label>\n"
"<input id=\"username\" name=\"username\" autocomplete=\"username\" "
"autocapitalize=\"none\" spellcheck=\"false\" maxlength=\"16\" required>\n"
"<label for=\"password\">Password</label>\n"
"<input id=\"password\" name=\"password\" type=\"password\" "
"autocomplete=\"current-password\" maxlength=\"64\" required>\n"
"<button id=\"submit\" type=\"submit\" disabled>Sign in</button>\n"
"</form>\n"
"<p id=\"message\" role=\"status\" aria-live=\"polite\">Ready to sign in.</p>\n"
"<noscript><p>JavaScript is required to sign in securely.</p></noscript>\n"
"<p><a href=\"/\">Return to console</a></p>\n"
"<small>Sessions expire after one hour, including active serial connections. "
"To switch accounts, return to the console and sign out first.</small>\n"
"</main>\n<script>\n"
"(() => {\n"
" 'use strict';\n"
" const form = document.getElementById('login');\n"
" const username = document.getElementById('username');\n"
" const password = document.getElementById('password');\n"
" const submit = document.getElementById('submit');\n"
" const message = document.getElementById('message');\n"
" const encoder = new TextEncoder();\n"
" let busy = false, generation = 0, controller = null, retryAt = 0;\n"
" function reset() {\n"
" ++generation;\n"
" if (controller) controller.abort();\n"
" controller = null; busy = false; password.value = '';\n"
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
" }\n"
" window.addEventListener('pagehide', reset);\n"
" window.addEventListener('pageshow', event => {\n"
" if (event.persisted) { reset(); message.textContent = 'Ready to sign in.'; }\n"
" });\n"
" async function readJSON(response) {\n"
" if (!response.body) throw new Error('response');\n"
" const reader = response.body.getReader();\n"
" const bytes = new Uint8Array(512);\n"
" let length = 0;\n"
" try {\n"
" for (;;) {\n"
" const part = await reader.read();\n"
" if (part.done) break;\n"
" if (part.value.length > bytes.length - length) throw new Error('response');\n"
" bytes.set(part.value, length); length += part.value.length;\n"
" }\n"
" return JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(bytes.subarray(0, length)));\n"
" } finally { await reader.cancel(); reader.releaseLock(); }\n"
" }\n"
" function report(response, stage) {\n"
" if (response.status === 429 || response.status === 503) {\n"
" const raw = response.headers.get('Retry-After') || '';\n"
" const seconds = /^[0-9]{1,3}$/.test(raw) ? Math.max(1, Math.min(120, Number(raw))) : 5;\n"
" retryAt = Date.now() + seconds * 1000;\n"
" message.textContent = (response.status === 429 ? 'Too many sign-in attempts.' : 'Sign-in capacity is busy.') +\n"
" ' Wait ' + seconds + ' seconds, then try again.';\n"
" } else if (response.status === 401 && stage === 'login') {\n"
" message.textContent = 'Username or password is incorrect. Please try again.';\n"
" } else if (response.status === 403) {\n"
" message.textContent = 'The sign-in challenge expired or the request was rejected. Please try again.';\n"
" } else if (response.status === 409) {\n"
" message.textContent = 'Already signed in. Return to the console; sign out there to switch accounts.';\n"
" } else if ([400, 413, 415].includes(response.status)) {\n"
" message.textContent = 'The sign-in request was not accepted. Check your input and try again.';\n"
" } else {\n"
" message.textContent = 'The device could not complete sign-in. Please try again.';\n"
" }\n"
" }\n"
" form.addEventListener('submit', async event => {\n"
" event.preventDefault();\n"
" if (busy) { password.value = ''; return; }\n"
" if (Date.now() < retryAt) {\n"
" password.value = '';\n"
" message.textContent = 'Please wait ' + Math.ceil((retryAt - Date.now()) / 1000) + ' seconds before retrying.';\n"
" return;\n"
" }\n"
" let body = JSON.stringify({username:username.value, password:password.value});\n"
" const valid = username.value.length && password.value.length &&\n"
" encoder.encode(username.value).length <= 16 && encoder.encode(password.value).length <= 64 &&\n"
" !username.value.includes('\\0') && !password.value.includes('\\0') && encoder.encode(body).length <= 512;\n"
" password.value = '';\n"
" if (!valid) { body = ''; message.textContent = 'Enter a username (up to 16 UTF-8 bytes) and password (up to 64 UTF-8 bytes).'; return; }\n"
" busy = true; submit.disabled = true; username.disabled = true; password.disabled = true; form.setAttribute('aria-busy', 'true');\n"
" message.textContent = 'Signing in...';\n"
" const current = ++generation;\n"
" const abort = new AbortController(); controller = abort;\n"
" const timeout = setTimeout(() => abort.abort(), 15000);\n"
" let csrf = '';\n"
" const options = {credentials:'same-origin', mode:'same-origin', cache:'no-store', redirect:'error', signal:abort.signal};\n"
" try {\n"
" const challenge = await fetch('/api/login-challenge', {...options, headers:{'X-Login-Bootstrap':'1'}});\n"
" if (current !== generation) return;\n"
" if (challenge.status !== 200) { report(challenge, 'challenge'); return; }\n"
" const data = await readJSON(challenge);\n"
" if (current !== generation) return;\n"
" if (!data || typeof data.csrf !== 'string' || !/^[0-9a-f]{64}$/.test(data.csrf) ||\n"
" !Number.isInteger(data.expires_in) || data.expires_in < 1 || data.expires_in > 120) throw new Error('challenge');\n"
" csrf = data.csrf;\n"
" const response = await fetch('/api/login', {...options, method:'POST',\n"
" headers:{'Content-Type':'application/json', 'X-CSRF-Token':csrf}, body});\n"
" body = ''; csrf = '';\n"
" if (current !== generation) return;\n"
" if (response.status !== 200) { report(response, 'login'); return; }\n"
" const result = await readJSON(response);\n"
" if (current !== generation) return;\n"
" if (!result || result.authenticated !== true) throw new Error('login');\n"
" window.location.replace('/');\n"
" } catch (_) {\n"
" if (current === generation) message.textContent = 'Could not confirm sign-in. Check the connection, then return to the console or try again.';\n"
" } finally {\n"
" abort.abort(); clearTimeout(timeout); body = ''; csrf = '';\n"
" if (current === generation) {\n"
" controller = null; busy = false; password.value = '';\n"
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
" }\n"
" }\n"
" });\n"
" submit.disabled = false;\n"
"})();\n"
"</script>\n</body>\n</html>\n";
esp_err_t web_login_ui_send_response(httpd_req_t *request)
{
if (request == NULL) return ESP_ERR_INVALID_ARG;
esp_err_t error = httpd_resp_set_type(request, "text/html; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Frame-Options", "DENY");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Content-Security-Policy",
"default-src 'none'; script-src 'sha256-x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo='; "
"style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'");
if (error == ESP_OK) error = httpd_resp_send(request, s_login_html, sizeof(s_login_html) - 1U);
return error;
}
+10
View File
@@ -0,0 +1,10 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
/* Standalone public login document for the future atomic 8D.3 cutover.
* Rendering only: no authentication, URI registration, or session allocation.
* Do not expose this page until its protected API and application routes exist. */
esp_err_t web_login_ui_send_response(httpd_req_t *request);
+47
View File
@@ -0,0 +1,47 @@
# Standalone login renderer host tests
From the repository root:
```sh
python3 tests/web_login_ui/run.py
# Optional compiler selection:
CC=clang python3 tests/web_login_ui/run.py
```
Requires Python 3, a C11 compiler supporting Linux shared libraries, and Node.js
24 on PATH. No pip/npm packages, ESP-IDF installation, network, device, or live
HTTP routes are needed. Do not run Python with `-O` (checks use assertions).
The runner compiles the actual `src/web_login_ui.c` and header with
`-Wall -Wextra -Werror` and tiny ESP/httpd stubs in an automatically removed
temporary directory. It checks NULL handling, all response setter failures
(stop immediately without sending), send error propagation, content type,
security headers, and the eight additional-header-slot budget. It parses the
captured HTML to reject external assets and compares the CSP script hash against
SHA256 of the exact rendered inline script bytes, including surrounding newlines.
JavaScript tests still run on a hash mismatch so it does not hide behavioral
test results.
`browser.cjs` executes that rendered script in Node's VM using DOM/fetch doubles,
real Response/ReadableStream, UTF-8 encoders/decoders, and AbortController. It
covers no automatic requests, challenge/custom-header and CSRF JSON submission,
redirect, wrong credentials/fresh challenges, status errors and manual-only
Retry-After backoff, malformed/oversized responses, network failure/timeout,
input byte limits, duplicate submission, and pagehide/pageshow generation safety
(including late fetches and late body reads), attempt-abort cleanup on every
error status, disabled pending inputs, and clearing re-entered passwords. Error
bodies carry a marker that
must never appear in displayed error text.
Limitations: this is not a real browser, ESP-IDF HTTP server, authentication
backend, or hardware test. It does not validate route registration, cookies,
TLS, native form validation, browser CSP enforcement, layout/accessibility, or
actual bfcache behavior. Timers and clock advancement are deterministic doubles;
fetch doubles can intentionally ignore abort to exercise stale completion paths.
Error-body stream abortion is modeled with fetch abort listeners, not real socket
cleanup. Clearing DOM fields/JavaScript references is best-effort secret lifetime
reduction, not guaranteed erasure of garbage-collected strings or browser internals.
The 16/64-byte field limits bound maximally escaped JSON to 509 bytes, so the
separate >512-byte request guard is not independently reachable with valid fields;
the suite tests worst-case expansion rather than bypassing those field checks.
Nothing here exposes a route or regenerates protected web assets.
+185
View File
@@ -0,0 +1,185 @@
'use strict';
const assert = require('node:assert/strict');
const vm = require('node:vm');
const {script} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
const token = 'a'.repeat(64), secret = 'SERVER_BODY_MUST_NOT_APPEAR';
const json = value => new Response(JSON.stringify(value));
const challenge = (csrf = token) => json({csrf, expires_in: 120});
const success = () => json({authenticated: true});
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
function browser(queue = []) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map();
for (const id of ['login', 'username', 'password', 'submit', 'message']) nodes[id] = {
value: '', disabled: id === 'submit', textContent: '', attrs: {},
setAttribute(k, v) { this.attrs[k] = v; },
addEventListener(k, fn) { this[k] = fn; }
};
let now = 100000, timerId = 0;
vm.runInNewContext(script, {
document: {getElementById: id => { assert.ok(nodes[id], id); return nodes[id]; }},
window: {addEventListener: (k, fn) => { events[k] = fn; }, location: {replace: p => redirects.push(p)}},
TextEncoder, TextDecoder, Uint8Array, AbortController, Response, Date: {now: () => now},
setTimeout: (fn, ms) => { timers.set(++timerId, {fn, ms}); return timerId; },
clearTimeout: id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, ...options});
assert.ok(queue.length, 'unexpected/automatic fetch');
const next = queue.shift();
return typeof next === 'function' ? next(options) : next;
}
}, {timeout: 1000});
assert.equal(calls.length, 0); assert.equal(timers.size, 0); assert.equal(nodes.submit.disabled, false);
return {nodes, events, calls, redirects, timers, queue, advance: ms => { now += ms; },
submit: (user = 'alice', pass = 'password') => {
nodes.username.value = user; nodes.password.value = pass;
let prevented = false;
const result = nodes.login.submit({preventDefault() { prevented = true; }});
assert.ok(prevented); return result;
},
idle() {
for (const id of ['submit', 'username', 'password']) assert.equal(nodes[id].disabled, false, id);
assert.equal(nodes.password.value, '');
assert.equal(timers.size, 0); assert.ok(!nodes.message.textContent.includes(secret));
}
};
}
let passed = 0;
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
(async () => {
await test('no automatic fetch; challenge GET, CSRF JSON POST, redirect', async () => {
const b = browser([challenge(), success()]); await b.submit(); b.idle();
assert.deepEqual(b.redirects, ['/']); assert.equal(b.calls.length, 2);
const [get, post] = b.calls;
assert.equal(get.url, '/api/login-challenge'); assert.equal(get.method || 'GET', 'GET');
assert.equal(get.headers['X-Login-Bootstrap'], '1'); assert.equal(get.body, undefined);
assert.equal(post.url, '/api/login'); assert.equal(post.method, 'POST');
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
assert.deepEqual(JSON.parse(post.body), {username: 'alice', password: 'password'});
for (const call of b.calls) {
for (const [k, v] of Object.entries({credentials: 'same-origin', mode: 'same-origin', cache: 'no-store', redirect: 'error'})) assert.equal(call[k], v);
assert.ok(call.signal instanceof AbortSignal);
}
});
await test('wrong credentials stay on page; manual retry obtains fresh challenge', async () => {
const b = browser([challenge(), new Response(secret, {status: 401})]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
assert.match(b.nodes.message.textContent, /incorrect/i);
b.queue.push(challenge('b'.repeat(64)), success()); await b.submit();
assert.equal(b.calls[2].url, '/api/login-challenge'); assert.equal(b.calls[3].headers['X-CSRF-Token'], 'b'.repeat(64));
});
await test('status errors at both stages; manual-only bounded backoff', async () => {
for (const stage of ['challenge', 'login']) for (const status of [400, 401, 403, 409, 413, 415, 429, 503, 500]) {
let response, aborted = false;
const b = browser([...(stage === 'login' ? [challenge()] : []), options => {
// Model fetch abort terminating its body stream, not a real HTTP socket.
const stream = new ReadableStream({start(controller) {
controller.enqueue(new TextEncoder().encode(secret));
options.signal.addEventListener('abort', () => {
aborted = true; controller.error(new DOMException('Aborted', 'AbortError'));
}, {once: true});
}});
response = new Response(stream, {status, headers: {'Retry-After': '2'}});
return response;
}]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
assert.ok(aborted, `${stage} ${status}: response stream abort`);
for (const call of b.calls) assert.ok(call.signal.aborted, `${stage} ${status}: signal`);
await assert.rejects(response.body.getReader().read(), {name: 'AbortError'});
assert.ok(b.nodes.message.textContent); const count = b.calls.length;
if ([429, 503].includes(status)) { await b.submit(); assert.equal(b.calls.length, count); }
b.advance(3000); await tick(); assert.equal(b.calls.length, count); assert.equal(b.timers.size, 0);
b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
for (const [raw, seconds] of [['0', 1], ['999', 120], ['bad', 5], ['1000', 5], ['', 5]]) {
const b = browser([new Response(secret, {status: 429, headers: {'Retry-After': raw}})]);
await b.submit(); assert.ok(b.nodes.message.textContent.includes(`Wait ${seconds} seconds`));
b.advance(seconds * 1000 - 1); await b.submit(); assert.equal(b.calls.length, 1);
b.advance(1); b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
});
await test('malformed, oversized, invalid UTF-8, absent and invalid-schema response bodies', async () => {
for (const stage of ['challenge', 'login']) {
const good = JSON.stringify(stage === 'challenge' ? {csrf: token, expires_in: 1} : {authenticated: true});
const invalid = [() => new Response(secret), () => new Response(null), () => new Response(Uint8Array.of(255)),
() => new Response(good.padEnd(513)), () => json(null), () => json({}), () => json([]),
...(stage === 'challenge' ? [() => json({csrf: token, expires_in: 0}), () => json({csrf: token, expires_in: 121}),
() => json({csrf: token, expires_in: 1.5}), () => json({csrf: token, expires_in: '1'}),
() => challenge('A'.repeat(64)), () => challenge('a'.repeat(63))] : [() => json({authenticated: 'true'}), () => json({authenticated: false})])];
for (const make of invalid) {
const b = browser([...(stage === 'login' ? [challenge()] : []), make()]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
assert.match(b.nodes.message.textContent, /Could not confirm/); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
}
const b = browser(stage === 'login' ? [challenge(), new Response(good.padEnd(512))] : [new Response(good.padEnd(512)), success()]);
await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
let cancelled = false;
const stream = new ReadableStream({start(c) { c.enqueue(new Uint8Array(300).fill(32)); c.enqueue(new Uint8Array(213).fill(32)); }, cancel() { cancelled = true; }});
const b = browser([new Response(stream)]); await b.submit(); b.idle(); assert.ok(cancelled);
});
await test('network errors at both stages; deadline abort; manual recovery', async () => {
for (const stage of ['challenge', 'login']) {
const b = browser([...(stage === 'login' ? [challenge()] : []), () => { throw new Error(secret); }]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []); assert.match(b.nodes.message.textContent, /Could not confirm/);
b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
const b = browser([o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error(secret))))]);
const pending = b.submit(); await tick(); const timer = [...b.timers.values()][0];
assert.equal(timer.ms, 15000); timer.fn(); await pending; b.idle(); assert.ok(b.calls[0].signal.aborted);
});
await test('UTF-8 and JSON byte limits, empty and NUL input, exact boundaries', async () => {
for (const [u, p] of [['', 'p'], ['u', ''], ['a'.repeat(17), 'p'], ['u', 'a'.repeat(65)],
['é'.repeat(9), 'p'], ['u', 'é'.repeat(33)], ['u\0', 'p'], ['u', 'p\0']]) {
const b = browser(); await b.submit(u, p); b.idle(); assert.equal(b.calls.length, 0); assert.match(b.nodes.message.textContent, /UTF-8 bytes/);
}
for (const [u, p] of [['a'.repeat(16), 'p'.repeat(64)], ['é'.repeat(8), '🔑'.repeat(16)], ['\u0001'.repeat(16), '\u0001'.repeat(64)]]) {
const b = browser([challenge(), success()]); await b.submit(u, p); assert.deepEqual(b.redirects, ['/']);
assert.deepEqual(JSON.parse(b.calls[1].body), {username: u, password: p});
assert.ok(new TextEncoder().encode(b.calls[1].body).length <= 512);
}
});
await test('pending inputs disabled; duplicate and completion wipe retyped passwords at both stages', async () => {
for (const stage of ['challenge', 'login']) for (const duplicate of [false, true]) {
const d = deferred(), b = browser(stage === 'login' ? [challenge(), d.promise] : [d.promise, success()]);
const pending = b.submit(); await tick();
assert.equal(b.nodes.password.value, ''); assert.equal(b.nodes.login.attrs['aria-busy'], 'true');
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
if (duplicate) {
await b.submit('alice', 'manually retyped duplicate');
assert.equal(b.nodes.password.value, ''); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
}
b.nodes.password.value = 'manually retyped before completion';
d.resolve(stage === 'login' ? success() : challenge()); await pending; b.idle();
assert.equal(b.nodes.login.attrs['aria-busy'], 'false'); assert.deepEqual(b.redirects, ['/']);
}
});
await test('pagehide aborts; late fetch/body ignored at both stages; pageshow recovers', async () => {
for (const stage of ['challenge', 'login']) for (const bodyPending of [false, true]) {
const d = deferred(); let streamController;
const response = bodyPending ? new Response(new ReadableStream({start(c) { streamController = c; }})) : d.promise;
const b = browser([...(stage === 'login' ? [challenge()] : []), response]);
const pending = b.submit(); await tick(); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
b.events.pagehide({}); assert.ok(b.calls[0].signal.aborted);
for (const id of ['submit', 'username', 'password']) assert.equal(b.nodes[id].disabled, false, id);
assert.equal(b.nodes.password.value, ''); b.events.pageshow({persisted: true});
assert.equal(b.nodes.message.textContent, 'Ready to sign in.');
// New generation remains busy even when the old request finishes.
const newer = deferred(); b.queue.push(newer.promise, success()); const retry = b.submit('new-user', 'new-password');
const newSignal = b.calls.at(-1).signal;
b.nodes.password.value = 'new generation field sentinel';
if (bodyPending) {
streamController.enqueue(new TextEncoder().encode(JSON.stringify(stage === 'login' ? {authenticated: true} : {csrf: token, expires_in: 60})));
streamController.close();
} else d.resolve(stage === 'login' ? success() : challenge());
await pending; assert.deepEqual(b.redirects, []);
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
assert.equal(b.nodes.username.value, 'new-user'); assert.equal(b.nodes.password.value, 'new generation field sentinel');
assert.equal(newSignal.aborted, false); assert.equal(b.timers.size, 1);
assert.equal(b.nodes.login.attrs['aria-busy'], 'true'); assert.equal(b.nodes.message.textContent, 'Signing in...');
newer.resolve(challenge()); await retry; b.idle(); assert.deepEqual(b.redirects, ['/']);
}
});
console.log(`PASS ${passed} browser test groups`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Compile the production renderer; test its C contract and rendered JavaScript."""
import base64
import ctypes as C
import hashlib
from html.parser import HTMLParser
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
STUB = r'''
#include "web_login_ui.h"
#include <string.h>
static int calls, sends, fail_at, send_error, count;
static const char *keys[8], *values[8], *html, *type;
void reset(int fail, int error) {
calls = sends = count = 0; fail_at = fail; send_error = error;
html = type = NULL;
}
int call_count(void) { return calls; }
int send_count(void) { return sends; }
int header_count(void) { return count; }
const char *header_key(int i) { return keys[i]; }
const char *header_value(int i) { return values[i]; }
const char *body(void) { return html; }
const char *content_type(void) { return type; }
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *v) {
(void)r; if (++calls == fail_at) return 73; type = v; return ESP_OK;
}
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) {
(void)r; if (++calls == fail_at) return 73;
if (count == 8) return 74;
keys[count] = k; values[count++] = v; return ESP_OK;
}
esp_err_t httpd_resp_send(httpd_req_t *r, const char *v, ssize_t n) {
(void)r; ++sends;
if (n < 0 || (size_t)n != strlen(v)) return 75;
html = v; return send_error;
}
'''
class Assets(HTMLParser):
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
assert 'src' not in attrs and 'srcset' not in attrs, (tag, attrs)
assert tag not in ('link', 'iframe', 'object', 'embed'), tag
assert not any(k.startswith('on') for k in attrs), attrs
def run():
with tempfile.TemporaryDirectory(prefix='web-login-ui-') as directory:
tmp = Path(directory)
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n'
'#define ESP_OK 0\n#define ESP_ERR_INVALID_ARG 258\n')
(tmp / 'esp_http_server.h').write_text('''#pragma once
#include "esp_err.h"
#include <sys/types.h>
typedef struct { int unused; } httpd_req_t;
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
''')
(tmp / 'stub.c').write_text(STUB)
subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + [
'-std=c11', '-Wall', '-Wextra', '-Werror', '-shared', '-fPIC',
'-I', str(tmp), '-I', str(ROOT / 'src'), str(tmp / 'stub.c'),
str(ROOT / 'src/web_login_ui.c'), '-o', str(tmp / 'renderer.so')], check=True)
lib = C.CDLL(str(tmp / 'renderer.so'))
lib.reset.argtypes = [C.c_int, C.c_int]
lib.web_login_ui_send_response.argtypes = [C.c_void_p]
for name in ('header_key', 'header_value', 'body', 'content_type'):
getattr(lib, name).restype = C.c_char_p
for name in ('header_key', 'header_value'):
getattr(lib, name).argtypes = [C.c_int]
request = C.c_int()
send = lambda: lib.web_login_ui_send_response(C.byref(request))
lib.reset(0, 0)
assert lib.web_login_ui_send_response(None) == 258
assert lib.call_count() == lib.send_count() == 0
assert send() == 0 and lib.send_count() == 1
count, calls = lib.header_count(), lib.call_count()
assert 0 < count <= 8
headers = {lib.header_key(i).decode(): lib.header_value(i).decode() for i in range(count)}
assert len(headers) == count
assert lib.content_type() == b'text/html; charset=utf-8'
html = lib.body().decode()
for key, value in {'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY'}.items():
assert headers[key] == value, (key, headers)
for failure in range(1, calls + 1):
lib.reset(failure, 0)
assert send() == 73 and lib.send_count() == 0
assert lib.call_count() == failure, 'header processing did not stop'
lib.reset(0, 91)
assert send() == 91 and lib.send_count() == 1
print('PASS C: NULL, headers, every setter failure, send error propagation', flush=True)
Assets().feed(html)
assert not re.search(r'url\s*\(|@import', html, re.I)
scripts = re.findall(r'<script>(.*?)</script>', html, re.S)
assert len(scripts) == 1
digest = base64.b64encode(hashlib.sha256(scripts[0].encode()).digest()).decode()
policy = dict(part.strip().split(' ', 1) for part in headers['Content-Security-Policy'].split(';') if part.strip())
expected = {"default-src": "'none'", "script-src": f"'sha256-{digest}'",
"connect-src": "'self'", "base-uri": "'none'", "form-action": "'none'",
"frame-ancestors": "'none'", "style-src": "'unsafe-inline'"}
# Report behavioral failures even when the script and CSP hash drift.
(tmp / 'rendered.json').write_text(json.dumps({'html': html, 'headers': headers, 'script': scripts[0]}))
result = subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], timeout=30)
assert policy == expected, f'CSP mismatch: expected {expected}, got {policy}'
assert result.returncode == 0, 'Node browser tests failed'
print(f'PASS HTML: standalone assets, {count}/8 header slots, exact CSP SHA256 {digest}')
if __name__ == '__main__':
run()