Add a hash-bound, no-store login document with focused C and Node host tests. Keep rendering inert until the 8D.3 authentication cutover.
122 lines
5.6 KiB
Python
122 lines
5.6 KiB
Python
#!/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()
|