Replace Web Basic Auth With Cookie Sessions

Add bounded login challenges, CSRF/origin enforcement, logout, and
session-bound WebSocket admission. Isolate private HTTPD access behind a
version-guarded adapter and add focused host coverage. Also let empty
admin
SSH input reach the normal console handler.
This commit is contained in:
2026-09-05 23:55:05 +02:00
parent 4435a7fddd
commit 5a609fa40b
36 changed files with 1940 additions and 360 deletions
+52
View File
@@ -0,0 +1,52 @@
# Existing serial app cookie-session cutover tests
Run from the repository root:
```sh
python3 tests/web_ui_session/run.py
```
Requires a host C compiler, Python 3, and Node with Fetch/Response/ReadableStream
support (Node 18+). All compiler outputs and rendered scripts are temporary; no
firmware build, generated assets, or device writes are performed.
The runner compiles production `src/web_ui.c` with HTTPD and vendored-asset data
doubles. It reuses the HTTPD stub text from `tests/web_login_ui/run.py`, without
importing/executing that runner. Node executes the actual C-rendered application
and inline asset-failure script, not a separately maintained implementation.
Coverage:
- Resource selection, NULL/invalid input, setter/send failure propagation,
eight-header ceiling, no-store document/application, unchanged vendor caching,
nosniff/no-referrer/frame denial, exact inline-loader CSP hash and login fallback.
- Session validation before initial/retried/restored connections; memory-only
CSRF header and empty ticket/logout bodies; safe-text username/absolute expiry.
- 401 shutdown and navigation once; manual recovery on 403; bounded Retry-After
display/backoff for capacity; network errors never assert successful logout.
- Confirmed 204 logout, lost response confirmed by session 401, uncertain logout,
cancellation, explicit recovery, and stale session/ticket/status/logout/WS work.
- Pagehide/bfcache restoration, late response bodies, and superseded session checks.
- Existing writer controls, 1,024-byte binary input chunks, raw binary output,
observer input gating, and explicit Disconnect pausing reconnect.
- Authentication/ticket response cap 512 bytes, existing status cap 3,072 bytes,
15-second request deadline, single status request in flight, bounded retry delay,
and unchanged 5,000-line terminal scrollback.
## Integration and known gaps
This is only the existing application browser portion of Phase 8D.3. It requires
the simultaneous server cookie/Origin/CSRF cutover for every route. The renderer
still relies on its caller to authenticate resources; protected asset failures
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
implemented here. No server, auth-store, transport, admin UI, or generated asset
changes are included.
These tests model DOM, timers, fetch cancellation and WebSocket events. They do
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
integrity. Full firmware build and mandatory M1 browser/target checks remain the
integrator's responsibility. The full build was deliberately not run in this
restricted-write subtask. No target resource reserve is claimed. Browser secret
references are dropped and never persisted/logged, but JavaScript cannot securely
wipe engine-managed strings.
+195
View File
@@ -0,0 +1,195 @@
'use strict';
const assert = require('node:assert/strict');
const vm = require('node:vm');
const {script, loader} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
const token = 'a'.repeat(64);
const json = value => new Response(JSON.stringify(value));
const session = (extra = {}) => json({username: '<img>', role: 'user', csrf: token, expires_in: 3600, ...extra});
const ticket = () => json({ticket: 't'.repeat(32)});
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/logout': []};
let serial = 0;
const on = (key, fn) => { (events[key] ||= []).push(fn); };
const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); };
const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; };
class Socket {
static OPEN = 1;
constructor(url) { this.url = url; this.readyState = 0; this.events = {}; this.sent = []; sockets.push(this); }
addEventListener(k, fn) { this.events[k] = fn; }
emit(k, event = {}) { if (k === 'open') this.readyState = 1; this.events[k]?.(event); }
close() { this.closed = true; this.readyState = 3; this.emit('close'); }
send(value) { this.sent.push(value); }
}
class Terminal {
constructor(options) { this.options = options; this.writes = []; terminals.push(this); }
loadAddon() {} open() {} resize() {} onData(fn) { this.input = fn; }
write(bytes) { this.writes.push([...bytes]); }
}
const window = {addEventListener: on, removeEventListener() {},
setTimeout: timeout, clearTimeout: id => timers.delete(id),
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
const context = vm.createContext({window, document: {getElementById(id) {
return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}},
getBoundingClientRect: () => ({width: 100, height: 100}),
addEventListener(k, fn) { this[k] = fn; }};
}}, Terminal, FitAddon: {FitAddon: class {proposeDimensions() { return null; }}},
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date, WebSocket: Socket,
fetch: async (url, options) => {
// Apply the Origin regression guard to every mutation, including logout.
assert.ok(Object.hasOwn(queues, url));
if (options.method === 'POST') assert.equal(options.mode, 'cors');
assert.equal(options.headers?.Origin, undefined);
calls.push({url, ...options});
const next = queues[url].shift();
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
if (url === '/api/session') return session();
if (url === '/api/status') return json({});
if (url === '/api/ws-ticket') return ticket();
throw new Error('network unavailable');
}});
if (withLoader || onlyLoader) vm.runInContext(loader, context);
const start = () => vm.runInContext(script, context);
const fire = ms => {
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
};
return {nodes, calls, redirects, timers, sockets, terminals, queues, emit, start, fire,
click: id => nodes[id].click(), window};
}
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
let passed = 0;
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
(async () => {
await test('bootstrap, CSRF, bounded expiry safe text, serial protocol and disconnect pause', async () => {
const b = await connected();
assert.equal(b.calls[0].url, '/api/session');
const post = b.calls.find(c => c.url === '/api/ws-ticket');
assert.equal(post.method, 'POST'); assert.equal(post.body, ''); assert.equal(post.headers['X-CSRF-Token'], token);
for (const call of b.calls) for (const [k, v] of Object.entries({credentials: 'same-origin', mode: call.method === 'POST' ? 'cors' : 'same-origin', cache: 'no-store', redirect: 'error'})) assert.equal(call[k], v);
assert.match(b.nodes['session-info'].textContent, /^<img>.*one hour absolute/);
const ws = b.sockets[0], term = b.terminals[0]; ws.emit('open');
ws.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})});
term.input('x'.repeat(2050)); assert.deepEqual(ws.sent.map(x => x.length), [1024, 1024, 2]);
ws.emit('message', {data: Uint8Array.of(0, 255, 13, 10).buffer}); assert.deepEqual(term.writes, [[0, 255, 13, 10]]);
b.click('release-control'); assert.equal(ws.sent.at(-1), 'release-writer');
ws.emit('message', {data: JSON.stringify({type: 'writer', writerId: 0, role: 'observer'})});
b.click('request-control'); assert.equal(ws.sent.at(-1), 'request-writer');
b.click('connection-toggle'); assert.ok(ws.closed); assert.equal(b.nodes['connection-toggle'].textContent, 'Connect');
const count = b.calls.length; ws.emit('close'); await tick(); assert.equal(b.calls.length, count);
b.click('connection-toggle'); await tick(); assert.equal(b.calls[count].url, '/api/session');
});
await test('401 at session/ticket/status stops everything and navigates only once', async () => {
for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) {
const b = browser(); b.queues[path].push(failure(401)); b.start(); await tick();
assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed));
assert.equal(b.timers.size, 0); b.window.sakSessionExpired(); assert.equal(b.redirects.length, 1);
b.click('connection-toggle'); await tick(); assert.equal(b.redirects.length, 1);
}
});
await test('403 mutation is manual-only; capacity backoff is not credentials and revalidates', async () => {
for (const status of [403, 429, 503]) {
const b = browser(); b.queues['/api/ws-ticket'].push(failure(status)); b.start(); await tick();
assert.deepEqual(b.redirects, []); assert.equal(b.sockets.length, 0);
if (status === 403) {
assert.match(b.nodes['connection-detail'].textContent, /security check/);
assert.equal(b.nodes['connection-toggle'].textContent, 'Connect'); b.click('connection-toggle');
} else {
assert.match(b.nodes['connection-detail'].textContent, /capacity or backoff/); b.fire(7000);
}
await tick(); assert.equal(b.calls.filter(c => c.url === '/api/session').length, 2);
assert.equal(b.sockets.length, 1);
}
});
await test('logout success, lost success, uncertain network and explicit recovery', async () => {
for (const outcome of ['204', 'lost401', 'lost200', 'offline', '403', '503']) {
const b = await connected();
b.queues['/api/logout'].push(outcome === '204' ? new Response(null, {status: 204}) :
['403', '503'].includes(outcome) ? failure(Number(outcome)) : () => { throw new Error('SECRET NETWORK'); });
if (outcome === 'lost401') b.queues['/api/session'].push(session(), failure(401));
if (outcome === 'offline') b.queues['/api/session'].push(session(), () => { throw new Error('offline'); });
await b.click('sign-out'); await tick(); assert.ok(b.sockets[0].closed);
assert.equal(b.calls.filter(c => c.url === '/api/logout').length, 1);
const post = b.calls.find(c => c.url === '/api/logout'); assert.equal(post.body, ''); assert.equal(post.headers['X-CSRF-Token'], token);
if (['204', 'lost401'].includes(outcome)) { assert.deepEqual(b.redirects, ['/login']); assert.equal(b.timers.size, 0); }
else {
assert.deepEqual(b.redirects, []); assert.match(b.nodes['connection-status'].textContent, /not confirmed/);
assert.ok(!b.nodes['connection-detail'].textContent.includes('SECRET'));
assert.equal(b.nodes['sign-out'].disabled, false);
const count = b.calls.length; b.click('connection-toggle'); await tick(); assert.equal(b.calls[count].url, '/api/session');
assert.equal(b.sockets.length, 2);
}
}
});
await test('logout cancels pending status/ticket/session; late 401 and WS events cannot affect new work', async () => {
for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) {
const d = deferred(), b = browser(); b.queues[path].push(d.promise); b.start(); await tick();
b.queues['/api/logout'].push(failure(403)); await b.click('sign-out'); await tick();
assert.ok(b.calls.find(c => c.url === path).signal.aborted);
const detail = b.nodes['connection-detail'].textContent;
d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.nodes['connection-detail'].textContent, detail);
}
const b = await connected(), old = b.sockets[0]; b.click('connection-toggle'); b.click('connection-toggle'); await tick();
old.emit('open'); old.emit('message', {data: JSON.stringify({type: 'hello', clientId: 99, writerId: 99, role: 'writer'})}); old.emit('error'); old.emit('close');
assert.equal(b.nodes['client-id'].textContent, '—'); assert.equal(b.terminals[0].options.disableStdin, true);
});
await test('pagehide/restore revalidates, preserves pause; late logout cannot navigate restored page', async () => {
for (const paused of [false, true]) {
const b = await connected(); if (paused) b.click('connection-toggle');
b.emit('pagehide'); const count = b.calls.length; b.emit('pageshow', {persisted: true}); await tick();
assert.equal(b.calls[count].url, '/api/session'); assert.equal(b.sockets.length, paused ? 1 : 2);
if (paused) assert.equal(b.nodes['connection-toggle'].textContent, 'Connect');
}
const b = await connected(), d = deferred(); b.queues['/api/logout'].push(d.promise);
const pending = b.click('sign-out'); await tick(); b.emit('pagehide'); b.emit('pageshow', {persisted: true}); await tick();
d.resolve(new Response(null, {status: 204})); await pending; assert.deepEqual(b.redirects, []);
});
await test('bounded schema/body validation, timeout, expiry and retry session checks', async () => {
for (const response of [session({csrf: 'A'.repeat(64)}), session({expires_in: 3601}), session({expires_in: -1}),
session({expires_in: 1.5}), session({role: 'root'}), session({username: 'x'.repeat(17)}),
new Response(' '.repeat(513)), new Response(Uint8Array.of(255)), json(null)]) {
const b = browser(); b.queues['/api/session'].push(response); b.start(); await tick();
assert.equal(b.sockets.length, 0); assert.equal(b.calls.length, 1); assert.ok(b.calls[0].signal.aborted);
}
const b = browser(); b.queues['/api/session'].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('timeout')))));
b.start(); b.fire(15000); await tick(); b.fire(1000); await tick(); assert.equal(b.calls[1].url, '/api/session');
const c = browser(); c.queues['/api/session'].push(session({expires_in: 2})); c.start(); await tick();
const expiry = [...c.timers.values()].find(timer => timer.ms >= 0 && timer.ms <= 2000);
assert.ok(expiry); c.fire(expiry.ms); assert.deepEqual(c.redirects, ['/login']);
});
await test('late body completions and superseded restore session are ignored', async () => {
for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) {
let stream;
const b = browser(); b.queues[path].push(new Response(new ReadableStream({start(c) { stream = c; }})));
b.start(); await tick(); b.emit('pagehide');
const text = path === '/api/session' ? {username: 'late', role: 'admin', csrf: token, expires_in: 3600} :
path === '/api/ws-ticket' ? {ticket: 't'.repeat(32)} : {wifi: {available: true, state: 'LATE'}};
stream.enqueue(new TextEncoder().encode(JSON.stringify(text))); stream.close(); await tick();
assert.ok(b.sockets.every(socket => socket.closed)); assert.deepEqual(b.redirects, []); assert.equal(b.timers.size, 0);
assert.ok(!b.nodes['wifi-summary'].textContent.includes('LATE'));
}
const b = await connected(); b.click('connection-toggle'); b.emit('pagehide');
const d = deferred(); b.queues['/api/session'].push(d.promise);
b.emit('pageshow', {persisted: true}); await tick(); b.click('connection-toggle'); await tick();
d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.sockets.length, 2);
});
await test('inline asset failures: 401 login, offline usable fallback, pagehide and shared navigation guard', async () => {
for (const status of [401, 503]) {
const b = browser({onlyLoader: true}); b.queues['/api/session'].push(failure(status));
b.emit('error', {target: {tagName: 'SCRIPT'}}); await tick();
assert.deepEqual(b.redirects, status === 401 ? ['/login'] : []); assert.equal(b.timers.size, 0);
b.emit('error', {target: {tagName: 'SCRIPT'}}); assert.equal(b.calls.length, 1);
}
const b = browser({onlyLoader: true}), d = deferred(); b.queues['/api/session'].push(d.promise);
b.emit('error', {target: {tagName: 'LINK'}}); b.emit('pagehide'); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []);
const c = browser({withLoader: true}); c.start(); await tick();
c.queues['/api/session'].push(failure(401)); c.emit('error', {target: {tagName: 'IMG'}}); await tick();
assert.deepEqual(c.redirects, ['/login']); assert.ok(c.sockets[0].closed); assert.equal(c.timers.size, 0);
});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Compile the actual C renderer and exercise its emitted app/loader in Node."""
import base64
import ctypes as C
import hashlib
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]
def run():
# Reuse only the HTTPD test double text, not the standalone login renderer.
source = (ROOT / 'tests/web_login_ui/run.py').read_text()
stub = source.split("STUB = r'''", 1)[1].split("'''", 1)[0].replace('web_login_ui.h', 'web_ui.h')
for asset in ('xterm_js_gz', 'xterm_css_gz', 'addon_fit_js_gz', 'logo_png'):
stub += f'\nconst unsigned char web_asset_{asset}[] = "stub";\nconst size_t web_asset_{asset}_size = 4;\n'
with tempfile.TemporaryDirectory(prefix='web-ui-session-') 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_ui.c'), '-o', str(tmp / 'renderer.so')], check=True)
lib = C.CDLL(str(tmp / 'renderer.so'))
lib.web_ui_send_response.argtypes = [C.c_void_p, C.c_int]
for name in ('header_key', 'header_value', 'body', 'content_type'):
getattr(lib, name).restype = C.c_char_p
request = C.c_int()
send = lambda resource: lib.web_ui_send_response(C.byref(request), resource)
lib.reset(0, 0)
assert lib.web_ui_send_response(None, 0) == 258
assert send(99) == 258 and lib.call_count() == 0
rendered = {}
for resource in range(6):
lib.reset(0, 0)
assert send(resource) == 0
count, calls = lib.header_count(), lib.call_count()
assert count <= 8
headers = {lib.header_key(i).decode(): lib.header_value(i).decode() for i in range(count)}
assert headers['Cache-Control'] == ('no-store' if resource in (0, 4) else 'private, max-age=604800')
assert headers['X-Content-Type-Options'] == 'nosniff'
assert headers['Referrer-Policy'] == 'no-referrer'
if resource == 0:
rendered.update(html=lib.body().decode(), headers=headers)
if resource == 4:
rendered['script'] = lib.body().decode()
for failure in range(1, calls + 1):
lib.reset(failure, 0)
assert send(resource) == 73 and lib.send_count() == 0
lib.reset(0, 91)
assert send(resource) == 91
scripts = re.findall(r'<script>(.*?)</script>', rendered['html'], re.S)
assert len(scripts) == 1
rendered['loader'] = scripts[0]
digest = base64.b64encode(hashlib.sha256(scripts[0].encode()).digest()).decode()
csp = rendered['headers']['Content-Security-Policy']
assert csp.count(f"'sha256-{digest}'") == 2, 'loader CSP hash mismatch'
assert "frame-ancestors 'none'" in csp and "connect-src 'self'" in csp
assert rendered['html'].index('<script>') < rendered['html'].index('/assets/xterm.js')
assert '<a href="/login">' in rendered['html']
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization'):
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
(tmp / 'rendered.json').write_text(json.dumps(rendered))
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback')
if __name__ == '__main__':
run()