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
+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()