From 4435a7fddd953b97c15def2256251a680ba91f2c Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 5 Sep 2026 18:45:12 +0200 Subject: [PATCH] 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. --- docs/agent/code-map.md | 1 + docs/agent/current-state.md | 13 ++- docs/phase8d_plan.md | 8 +- src/CMakeLists.txt | 1 + src/web_login_ui.c | 160 ++++++++++++++++++++++++++++ src/web_login_ui.h | 10 ++ tests/web_login_ui/README.md | 47 +++++++++ tests/web_login_ui/browser.cjs | 185 +++++++++++++++++++++++++++++++++ tests/web_login_ui/run.py | 121 +++++++++++++++++++++ 9 files changed, 542 insertions(+), 4 deletions(-) create mode 100644 src/web_login_ui.c create mode 100644 src/web_login_ui.h create mode 100644 tests/web_login_ui/README.md create mode 100644 tests/web_login_ui/browser.cjs create mode 100644 tests/web_login_ui/run.py diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 0c479d0..7d48e79 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -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 diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 83d7885..0c21718 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -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 600–800-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. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 106c39f..a9d1840 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -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.4–8D.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.4–8D.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.4–8D.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.4–8D.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: diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e751227..3fd6ad7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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" diff --git a/src/web_login_ui.c b/src/web_login_ui.c new file mode 100644 index 0000000..06bdbf5 --- /dev/null +++ b/src/web_login_ui.c @@ -0,0 +1,160 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_login_ui.h" + +#include + +/* Authored standalone page; no dependency on protected or generated assets. */ +static const char s_login_html[] = + "\n\n\n" + "\n" + "\n" + "Sign in - ESP32 Serial Console\n" + "\n\n\n
\n" + "

ESP32 Serial Console

\n" + "

Sign in to access the serial terminal.

\n" + "
\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "
\n" + "

Ready to sign in.

\n" + "\n" + "

Return to console

\n" + "Sessions expire after one hour, including active serial connections. " + "To switch accounts, return to the console and sign out first.\n" + "
\n\n\n\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; +} diff --git a/src/web_login_ui.h b/src/web_login_ui.h new file mode 100644 index 0000000..e368b08 --- /dev/null +++ b/src/web_login_ui.h @@ -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); diff --git a/tests/web_login_ui/README.md b/tests/web_login_ui/README.md new file mode 100644 index 0000000..0a51501 --- /dev/null +++ b/tests/web_login_ui/README.md @@ -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. diff --git a/tests/web_login_ui/browser.cjs b/tests/web_login_ui/browser.cjs new file mode 100644 index 0000000..4b67252 --- /dev/null +++ b/tests/web_login_ui/browser.cjs @@ -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; }); diff --git a/tests/web_login_ui/run.py b/tests/web_login_ui/run.py new file mode 100644 index 0000000..dd18a21 --- /dev/null +++ b/tests/web_login_ui/run.py @@ -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 +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 +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'', 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()