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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
@@ -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; });
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user