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:
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused policy test: actual project helper plus installed IDF argv parser.
|
||||
|
||||
Requires Python 3, cc and IDF_PATH (defaults to PlatformIO's installed SDK).
|
||||
Does not run FreeRTOS dispatch, SSH I/O or target hardware.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
source = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
start = source.index("static bool remote_command_allowed(")
|
||||
helper = source[start:source.index("\n}", start) + 2]
|
||||
prelude = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
|
||||
typedef struct { char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; } admin_request_t;
|
||||
size_t esp_console_split_argv(char *, char **, size_t);
|
||||
static void secure_wipe(void *p, size_t n) {
|
||||
volatile unsigned char *bytes = p;
|
||||
while (n--) *bytes++ = 0;
|
||||
}
|
||||
'''
|
||||
cases = r'''
|
||||
int main(void) {
|
||||
const struct { const char *line; bool allowed; } cases[] = {
|
||||
{"", true}, {" ", true}, {" ", true},
|
||||
{"memory", true}, {"user", true}, {"user list", true},
|
||||
{"user show bootstrap", true}, {"exit", true},
|
||||
{"user bootstrap", false}, {"user bootstrap extra", false},
|
||||
{"user recover", false}, {"user recover --force", false},
|
||||
{" user recover --force ", false},
|
||||
{"\"user\" \"bootstrap\"", false},
|
||||
{"\"user\" \"recover\" --force", false},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); ++i) {
|
||||
admin_request_t request = {0};
|
||||
strcpy(request.line, cases[i].line);
|
||||
assert(remote_command_allowed(&request) == cases[i].allowed);
|
||||
assert(!strcmp(request.line, cases[i].line));
|
||||
}
|
||||
puts("PASS: empty input/ordinary commands allowed; physical-only commands (including quoted forms) remain denied");
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
|
||||
path = Path(directory)
|
||||
(path / "test.c").write_text(prelude + helper + cases)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
|
||||
"-o", str(path / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Cookie authentication and HTTPD adapter host checks
|
||||
|
||||
Run from the project root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
```
|
||||
|
||||
Requires Python 3, a C11 compiler (`cc`), OpenSSL headers/libcrypto, and the pinned ESP-IDF source installation. The runner uses `IDF_PATH` when set, otherwise `~/.platformio/packages/framework-espidf`. It writes only an automatically removed temporary directory. No network, device, pip/npm packages or server is needed. Do not disable C assertions.
|
||||
|
||||
The runner compiles production `web_cookie_auth`, `web_session_store`, `web_auth_parse` and `web_httpd_adapter` with bounded HTTPD/database/time/RNG doubles. It also executes the session-store public API suite. The installed IDF header getters, append-only response-header setter and right-aligned pending-data reader are extracted verbatim and compiled into the harness.
|
||||
|
||||
Coverage includes challenge reuse/consumption/expiry, capacities without eviction, global throttle, fragmented login bodies, secure cookie attributes and two simultaneous Set-Cookie fields, session-specific logout, duplicate fields/cookies, Origin/CSRF/method/Fetch Metadata rejection, Basic denial, currentness, stop/login and failure paths, six-header login budget, upgrade-state installation, and request cleanup preserving all 0–128 pending lengths through partial reads.
|
||||
|
||||
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency remain M1 target gates. No sanitizer or runtime memory-reserve result is implied.
|
||||
|
||||
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production policy/store/adapter with bounded HTTPD and database doubles.
|
||||
|
||||
Getter and append-header functions are extracted verbatim from installed IDF,
|
||||
not reimplemented with convenient merging or overwrite semantics.
|
||||
"""
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
os.environ["CCACHE_DISABLE"] = "1"
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
sys.path.insert(0, str(HERE.parent / "web_session_store"))
|
||||
from run import HEADERS
|
||||
from serial_headers import SERIAL_HEADERS
|
||||
|
||||
HEADERS.update(SERIAL_HEADERS)
|
||||
HEADERS["esp_http_server.h"] += """
|
||||
#define ESP_ERR_HTTPD_INVALID_REQ 0x200
|
||||
#define ESP_ERR_HTTPD_RESULT_TRUNC 0x201
|
||||
#define ESP_ERR_HTTPD_RESP_HDR 0x202
|
||||
esp_err_t httpd_resp_sendstr(httpd_req_t *, const char *);
|
||||
int httpd_req_recv(httpd_req_t *, char *, size_t);
|
||||
"""
|
||||
HEADERS["esp_idf_version.h"] = """
|
||||
#define ESP_IDF_VERSION_VAL(a,b,c) ((a)*10000+(b)*100+(c))
|
||||
#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(5,5,0)
|
||||
"""
|
||||
HEADERS["esp_httpd_priv.h"] = """#pragma once
|
||||
#include "esp_http_server.h"
|
||||
struct sock_db { bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *);
|
||||
bool ws_control_frames; void *ws_user_ctx; char pending_data[128]; size_t pending_len; };
|
||||
struct httpd_req_aux { struct sock_db *sd; char *scratch; size_t scratch_cur_size, remaining_len;
|
||||
unsigned req_hdrs_count, resp_hdrs_count; bool ws_handshake_detect;
|
||||
struct resp_hdr { const char *field, *value; } *resp_hdrs; };
|
||||
struct httpd_data { struct { unsigned max_resp_headers; } config; };
|
||||
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
|
||||
"""
|
||||
|
||||
def function(source, name):
|
||||
start = source.index(name + "(")
|
||||
start = source.rfind("\n", 0, start) + 1
|
||||
end = source.index("\n}", start) + 2
|
||||
return source[start:end]
|
||||
|
||||
idf = pathlib.Path(os.environ.get("IDF_PATH", str(pathlib.Path.home() / ".platformio/packages/framework-espidf")))
|
||||
parse = (idf / "components/esp_http_server/src/httpd_parse.c").read_text()
|
||||
txrx = (idf / "components/esp_http_server/src/httpd_txrx.c").read_text()
|
||||
extracted = """
|
||||
#pragma GCC diagnostic ignored "-Wsign-compare"
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include "esp_httpd_priv.h"
|
||||
#define ESP_LOGD(...) ((void)0)
|
||||
#define MIN(a,b) ((a) < (b) ? (a) : (b))
|
||||
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
|
||||
static size_t strlcpy(char *d, const char *s, size_t n) {
|
||||
size_t len = strlen(s); if (n) { size_t m = len < n-1 ? len : n-1; memcpy(d,s,m); d[m]=0; } return len;
|
||||
}
|
||||
"""
|
||||
for name in ["httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str"]:
|
||||
# The declarations start at line beginning; avoid earlier calls in parser.
|
||||
prefix = "size_t " if name.endswith("len") else "esp_err_t "
|
||||
extracted += function(parse[parse.index(prefix + name):], name) + "\n"
|
||||
extracted += function(txrx[txrx.index("esp_err_t httpd_resp_set_hdr"):], "httpd_resp_set_hdr")
|
||||
extracted += "\n" + function(txrx[txrx.index("static size_t httpd_recv_pending"):], "httpd_recv_pending")
|
||||
extracted += "\nsize_t host_read_pending(httpd_req_t *r, char *out, size_t n) { return httpd_recv_pending(r, out, n); }\n"
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
tmp = pathlib.Path(directory)
|
||||
for name, text in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
(tmp / "installed_httpd.c").write_text(extracted)
|
||||
sources = [HERE / "test.c", tmp / "installed_httpd.c"]
|
||||
sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]]
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
@@ -0,0 +1,257 @@
|
||||
/* Production store dependency doubles and its existing public API suite. */
|
||||
#define main store_tests
|
||||
#include "../web_session_store/test.c"
|
||||
#undef main
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
|
||||
static struct httpd_data server = {.config.max_resp_headers = 8};
|
||||
static struct sock_db socket_state;
|
||||
static struct resp_hdr response_headers[8];
|
||||
static char scratch[1024], output[1024], cookie_values[2][200];
|
||||
static const char *request_body;
|
||||
static size_t body_offset;
|
||||
static unsigned password_calls, cookie_count, sends, upgrades;
|
||||
static unsigned fail_header, setter_calls;
|
||||
static bool send_fail, recv_fail;
|
||||
static void (*password_hook)(void);
|
||||
static char response_status[48];
|
||||
static struct httpd_req_aux aux;
|
||||
static httpd_req_t req;
|
||||
size_t host_read_pending(httpd_req_t *r, char *out, size_t n);
|
||||
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *status) {
|
||||
(void)r; if (fail_header && ++setter_calls == fail_header) return ESP_FAIL;
|
||||
snprintf(response_status, sizeof(response_status), "%s", status); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *type) {
|
||||
(void)r; (void)type; return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
|
||||
(void)r; ++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body);
|
||||
cookie_count = 0;
|
||||
for (unsigned i = 0; i < aux.resp_hdrs_count; ++i) {
|
||||
assert(response_headers[i].value);
|
||||
assert(strcmp(response_headers[i].field, "WWW-Authenticate"));
|
||||
if (!strcmp(response_headers[i].field, "Set-Cookie")) {
|
||||
assert(cookie_count < 2);
|
||||
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
|
||||
}
|
||||
}
|
||||
return send_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
|
||||
(void)r; if (recv_fail) return -1;
|
||||
if (size > 7) size = 7; /* Fragment every login body. */
|
||||
memcpy(out, request_body + body_offset, size); body_offset += size;
|
||||
aux.remaining_len -= size; return (int)size;
|
||||
}
|
||||
esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); }
|
||||
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) {
|
||||
web_session_store_invalidate(id); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol) {
|
||||
(void)r; (void)protocol; ++upgrades; return ESP_OK;
|
||||
}
|
||||
esp_err_t user_database_authenticate_password(const uint8_t *u, size_t un,
|
||||
const uint8_t *p, size_t pn, user_principal_t *principal, bool *authenticated) {
|
||||
assert(!host_lock_depth); ++password_calls;
|
||||
if (password_hook) { void (*hook)(void) = password_hook; password_hook = NULL; hook(); }
|
||||
*authenticated = un == 5 && !memcmp(u, "alice", 5) && pn == 12 && !memcmp(p, "password1234", 12);
|
||||
if (*authenticated) *principal = alice;
|
||||
return db_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
|
||||
static void begin(const char *uri, int method, const char *body) {
|
||||
memset(scratch, 0, sizeof(scratch)); memset(response_headers, 0, sizeof(response_headers));
|
||||
memset(&socket_state, 0, sizeof(socket_state));
|
||||
aux = (struct httpd_req_aux){.sd = &socket_state, .scratch = scratch,
|
||||
.scratch_cur_size = sizeof(scratch), .resp_hdrs = response_headers};
|
||||
req = (httpd_req_t){.handle = &server, .aux = &aux, .uri = uri, .method = method,
|
||||
.content_len = body ? strlen(body) : 0};
|
||||
aux.remaining_len = req.content_len;
|
||||
request_body = body; body_offset = 0;
|
||||
response_status[0] = output[0] = 0; cookie_count = 0;
|
||||
}
|
||||
static void add(const char *key, const char *value) {
|
||||
char *at = scratch;
|
||||
for (unsigned i = 0; i < aux.req_hdrs_count; ++i) at += strlen(at) + 1;
|
||||
assert((size_t)(at - scratch) + strlen(key) + strlen(value) + 3 < sizeof(scratch));
|
||||
sprintf(at, "%s: %s", key, value); ++aux.req_hdrs_count;
|
||||
}
|
||||
static void same_origin(void) { add("Host", "device.example"); add("Origin", origin); }
|
||||
static void expect(const char *status) {
|
||||
(void)web_cookie_auth_handler(&req);
|
||||
assert(!strcmp(response_status, status));
|
||||
}
|
||||
static void token_from(const char *value, char token[65]) {
|
||||
const char *start = strchr(value, '='); assert(start && strlen(start + 1) >= 64);
|
||||
memcpy(token, start + 1, 64); token[64] = 0;
|
||||
}
|
||||
static void csrf_from(char csrf[65]) {
|
||||
const char *start = strstr(output, "\"csrf\":\""); assert(start);
|
||||
memcpy(csrf, start + 8, 64); csrf[64] = 0;
|
||||
}
|
||||
static void challenge(char token[65], char csrf[65]) {
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example");
|
||||
add("X-Login-Bootstrap", "1"); expect("200 OK");
|
||||
assert(cookie_count == 1); token_from(cookie_values[0], token); csrf_from(csrf);
|
||||
}
|
||||
static const char good_body[] = "{\"username\":\"alice\",\"password\":\"password1234\"}";
|
||||
static void login_request(const char *token, const char *csrf, const char *body) {
|
||||
begin("/api/login", HTTP_POST, body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||
}
|
||||
static void auth_reset(void) {
|
||||
web_cookie_auth_stop(); reset(); assert(web_cookie_auth_start() == ESP_OK);
|
||||
password_calls = 0; password_hook = NULL;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
char token[65], csrf[65], session[65], cookies[200];
|
||||
challenge(token, csrf);
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||
expect("200 OK"); assert(cookie_count == 0);
|
||||
login_request(token, csrf, good_body); expect("200 OK");
|
||||
assert(password_calls == 1 && cookie_count == 2 && snapshot().active == 1);
|
||||
assert(strstr(cookie_values[0], "__Host-sak-prelogin="));
|
||||
assert(strstr(cookie_values[0], "Max-Age=0"));
|
||||
assert(strstr(cookie_values[1], "Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600"));
|
||||
token_from(cookie_values[1], session);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin();
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", session); add("Cookie", cookies);
|
||||
expect("200 OK"); csrf_from(csrf);
|
||||
issued_t other = mint(&alice);
|
||||
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies); add("X-CSRF-Token", csrf);
|
||||
expect("204 No Content"); present(&other); assert(snapshot().active == 1);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
puts("PASS: challenge reuse, fragmented login, two independent Set-Cookie fields, session and isolated logout");
|
||||
|
||||
auth_reset();
|
||||
for (unsigned i = 0; i < 5; ++i) {
|
||||
challenge(token, csrf); login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"wrong\"}");
|
||||
expect("401 Unauthorized");
|
||||
login_request(token, csrf, good_body); expect("403 Forbidden");
|
||||
}
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("429 Too Many Requests");
|
||||
assert(password_calls == 5); now += 60000000;
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("200 OK");
|
||||
auth_reset(); for (unsigned i = 0; i < 4; ++i) challenge(token, csrf);
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
||||
now += 120000000; challenge(token, csrf);
|
||||
auth_reset(); for (unsigned i = 0; i < 4; ++i) (void)mint(&alice);
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("503 Service Unavailable"); assert(snapshot().active == 4);
|
||||
puts("PASS: consumed challenges, global five/60s throttle, expiry and no live challenge/session eviction");
|
||||
|
||||
auth_reset(); challenge(token, csrf);
|
||||
const char *keys[] = {"Host", "Origin", "Cookie", "Content-Type", "X-CSRF-Token"};
|
||||
for (unsigned i = 0; i < sizeof(keys)/sizeof(keys[0]); ++i) {
|
||||
login_request(token, csrf, good_body); add(keys[i], "ambiguous"); expect("400 Bad Request");
|
||||
}
|
||||
assert(password_calls == 0);
|
||||
login_request(token, csrf, good_body); add("Transfer-Encoding", "chunked"); expect("400 Bad Request");
|
||||
login_request(token, csrf, good_body); add("Sec-Fetch-Site", "cross-site"); expect("403 Forbidden");
|
||||
login_request(token, "invalid", good_body); expect("403 Forbidden");
|
||||
login_request(token, csrf, good_body); req.method = HTTP_GET; expect("400 Bad Request");
|
||||
login_request(token, csrf, good_body); req.content_len = 513; expect("413 Payload Too Large");
|
||||
login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"x\",\"unknown\":1}"); expect("400 Bad Request");
|
||||
assert(password_calls == 0);
|
||||
begin("/", HTTP_GET, NULL); same_origin(); add("Authorization", "Basic ignored");
|
||||
web_session_view_t view; bool allowed;
|
||||
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
||||
assert(!strcmp(response_status, "303 See Other"));
|
||||
begin("/assets/app.js", HTTP_GET, NULL); same_origin();
|
||||
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
||||
assert(!strcmp(response_status, "401 Unauthorized"));
|
||||
puts("PASS: duplicate security headers, framing, methods, metadata, CSRF, strict JSON and no Basic bypass");
|
||||
|
||||
auth_reset(); other = mint(&alice);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example");
|
||||
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example"); add("Origin", "https://foreign.example");
|
||||
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "device.example"); add("Cookie", cookies);
|
||||
expect("200 OK");
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "alias.local"); add("Cookie", cookies);
|
||||
expect("401 Unauthorized"); present(&other);
|
||||
challenge(token, csrf);
|
||||
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-prelogin=%s", other.token, token);
|
||||
add("Cookie", cookies); expect("409 Conflict"); assert(password_calls == 0); present(&other);
|
||||
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-session=%s", other.token, other.token);
|
||||
add("Cookie", cookies); expect("400 Bad Request"); assert(password_calls == 0);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
now = other.view.expires_at_us;
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
auth_reset(); other = mint(&alice); stale_user = alice.user_id;
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
puts("PASS: mandatory mutation Origin/CSRF, origin binding, explicit account switching, duplicate named cookies, expiry/currentness");
|
||||
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
password_hook = web_cookie_auth_stop; expect("503 Service Unavailable"); assert(!snapshot().active);
|
||||
auth_reset(); rng_fail = true;
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); send_fail = true;
|
||||
assert(web_cookie_auth_handler(&req) != ESP_OK); send_fail = false; assert(!snapshot().active);
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); recv_fail = true;
|
||||
expect("400 Bad Request"); recv_fail = false; assert(!password_calls);
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "first"); add("host", "second");
|
||||
char value[32]; assert(httpd_req_get_hdr_value_str(&req, "Host", value, sizeof(value)) == ESP_OK);
|
||||
assert(!strcmp(value, "first") && !web_httpd_headers_valid(&req));
|
||||
begin("/ws/serial", HTTP_GET, NULL); same_origin();
|
||||
add("Sec-WebSocket-Version", "13"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
assert(!web_httpd_upgrade_requested(&req)); aux.ws_handshake_detect = true;
|
||||
assert(web_httpd_upgrade(&req, web_cookie_auth_handler) == ESP_OK && upgrades == 1);
|
||||
assert(socket_state.ws_handshake_done && !web_httpd_upgrade_requested(&req));
|
||||
begin("/ws/serial", HTTP_GET, NULL); same_origin(); aux.ws_handshake_detect = true;
|
||||
add("Sec-WebSocket-Version", "130"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
assert(!web_httpd_upgrade_requested(&req));
|
||||
for (size_t keep = 0; keep <= sizeof(socket_state.pending_data); ++keep) {
|
||||
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
||||
size_t offset = sizeof(socket_state.pending_data) - keep;
|
||||
memset(socket_state.pending_data + offset, 'p', keep);
|
||||
socket_state.pending_len = keep;
|
||||
web_httpd_wipe_request(&req, false);
|
||||
zero(socket_state.pending_data, offset);
|
||||
char received[128];
|
||||
size_t first = host_read_pending(&req, received, 1);
|
||||
assert(first == (keep ? 1U : 0U));
|
||||
if (first) assert(received[0] == 'p');
|
||||
web_httpd_wipe_request(&req, false);
|
||||
zero(socket_state.pending_data, offset + first);
|
||||
size_t remaining = host_read_pending(&req, received, sizeof(received));
|
||||
assert(remaining == keep - first);
|
||||
for (size_t i = 0; i < remaining; ++i) assert(received[i] == 'p');
|
||||
}
|
||||
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
||||
socket_state.pending_len = 3; web_httpd_wipe_request(&req, true);
|
||||
zero(socket_state.pending_data, sizeof(socket_state.pending_data));
|
||||
zero(scratch, sizeof(scratch));
|
||||
puts("PASS: request wiping preserves right-aligned pending data through actual IDF reader (all lengths/partial reads)");
|
||||
puts("PASS: stop/login race, RNG/send/receive failure, actual IDF first-header semantics and explicit upgrade adapter");
|
||||
for (unsigned limit = 0; limit < 6; ++limit) {
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
server.config.max_resp_headers = limit;
|
||||
assert(web_cookie_auth_handler(&req) != ESP_OK);
|
||||
assert(snapshot().active == 0);
|
||||
server.config.max_resp_headers = 8;
|
||||
}
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2);
|
||||
server.config.max_resp_headers = 8;
|
||||
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
|
||||
return 0;
|
||||
}
|
||||
@@ -23,6 +23,10 @@ function browser(queue = []) {
|
||||
setTimeout: (fn, ms) => { timers.set(++timerId, {fn, ms}); return timerId; },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
fetch: async (url, options) => {
|
||||
// Guard the no-referrer/Origin:null regression; this VM does not synthesize browser headers.
|
||||
assert.ok(['/api/login-challenge', '/api/login'].includes(url));
|
||||
if (options.method === 'POST') assert.equal(options.mode, 'cors');
|
||||
assert.equal(options.headers?.Origin, undefined);
|
||||
calls.push({url, ...options});
|
||||
assert.ok(queue.length, 'unexpected/automatic fetch');
|
||||
const next = queue.shift();
|
||||
@@ -57,7 +61,7 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
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);
|
||||
for (const [k, v] of Object.entries({credentials: 'same-origin', mode: 'cors', cache: 'no-store', redirect: 'error'})) assert.equal(call[k], v);
|
||||
assert.ok(call.signal instanceof AbortSignal);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,7 +62,8 @@ def run():
|
||||
*sanitizer,
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"),
|
||||
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||
str(HERE / ("serial_test.c" if serial else "test.c")), str(source), *crypto,
|
||||
str(HERE / ("serial_test.c" if serial else "test.c")), str(source),
|
||||
*([str(ROOT / "src/web_auth_parse.c")] if serial else []), *crypto,
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=10)
|
||||
|
||||
|
||||
@@ -24,12 +24,13 @@ TaskHandle_t xTaskCreateStatic(void (*)(void *), const char *, uint32_t, void *,
|
||||
#include "esp_err.h"
|
||||
typedef void *httpd_handle_t;
|
||||
typedef struct { httpd_handle_t handle; void *sess_ctx; void (*free_ctx)(void *);
|
||||
int method; size_t content_len; } httpd_req_t;
|
||||
int method; size_t content_len; const char *uri; void *aux; } httpd_req_t;
|
||||
typedef enum { HTTPD_WS_TYPE_CONTINUE, HTTPD_WS_TYPE_TEXT, HTTPD_WS_TYPE_BINARY } httpd_ws_type_t;
|
||||
typedef enum { HTTPD_WS_CLIENT_HTTP, HTTPD_WS_CLIENT_WEBSOCKET } httpd_ws_client_info_t;
|
||||
typedef struct { bool final, fragmented; httpd_ws_type_t type; unsigned char *payload;
|
||||
size_t len; } httpd_ws_frame_t;
|
||||
#define HTTP_POST 1
|
||||
#define HTTP_GET 0
|
||||
size_t httpd_req_get_url_query_len(httpd_req_t *);
|
||||
esp_err_t httpd_req_get_url_query_str(httpd_req_t *, char *, size_t);
|
||||
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *, const char *, char *, size_t);
|
||||
|
||||
@@ -10,15 +10,29 @@ static unsigned broker_connections, broker_disconnects, writes, closes;
|
||||
static esp_err_t close_result = ESP_OK;
|
||||
static httpd_req_t request = { .handle = (void *)1 };
|
||||
static void (*connect_hook)(void);
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { (void)r; (void)s; return ESP_OK; }
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { (void)r; (void)s; return ESP_OK; }
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) { (void)r; (void)k; (void)v; return ESP_OK; }
|
||||
esp_err_t httpd_resp_send(httpd_req_t *r, const char *s, int n) { (void)r; (void)s; (void)n; return ESP_OK; }
|
||||
void xTaskNotifyGive(TaskHandle_t task) { (void)task; assert(!host_lock_depth); }
|
||||
size_t httpd_req_get_url_query_len(httpd_req_t *r) { (void)r; return strlen(query); }
|
||||
esp_err_t httpd_req_get_url_query_str(httpd_req_t *r, char *out, size_t n) {
|
||||
(void)r; assert(strlen(query) < n); strcpy(out, query); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *r, const char *key, char *out, size_t n) {
|
||||
(void)r; (void)key; (void)out; (void)n; return ESP_ERR_NOT_FOUND;
|
||||
(void)r;
|
||||
const char *value = !strcmp(key, "Host") ? "device.example" :
|
||||
!strcmp(key, "Origin") ? "https://device.example" : NULL;
|
||||
if (!value) return ESP_ERR_NOT_FOUND;
|
||||
assert(strlen(value) < n); strcpy(out, value); return ESP_OK;
|
||||
}
|
||||
size_t httpd_req_get_hdr_value_len(httpd_req_t *r, const char *key) {
|
||||
char value[140]; return httpd_req_get_hdr_value_str(r, key, value, sizeof(value)) == ESP_OK ? strlen(value) : 0;
|
||||
}
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *r) { (void)r; return true; }
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *r, esp_err_t (*handler)(httpd_req_t *)) {
|
||||
(void)r; (void)handler; return ESP_OK;
|
||||
}
|
||||
size_t httpd_req_get_hdr_value_len(httpd_req_t *r, const char *key) { (void)r; (void)key; return 0; }
|
||||
int httpd_req_to_sockfd(httpd_req_t *r) { (void)r; return 10; }
|
||||
httpd_ws_client_info_t httpd_ws_get_fd_info(httpd_handle_t h, int fd) {
|
||||
(void)h; (void)fd; return HTTPD_WS_CLIENT_WEBSOCKET;
|
||||
@@ -126,12 +140,9 @@ int main(void) {
|
||||
now += WEB_SERIAL_CURRENTNESS_INTERVAL_US; stale_user = alice.user_id; db_hook = reuse_slot_hook;
|
||||
process_principal_currentness(sa); assert(!sa->close_requested);
|
||||
|
||||
/* Basic tickets still work with a disabled store, but never accept bound tickets. */
|
||||
/* Zero identity is no longer a Basic compatibility route. */
|
||||
serial_reset(); web_session_store_stop();
|
||||
assert(web_serial_transport_mint_ticket(&alice, 0, ta, sizeof(ta)) == ESP_OK);
|
||||
snprintf(query, sizeof(query), "ticket=%s", ta);
|
||||
assert(connect_websocket(&request, 10, 0) == ESP_OK);
|
||||
before = writes; assert(process_websocket_frame(&request) == ESP_OK && writes == before + 1);
|
||||
assert(web_serial_transport_mint_ticket(&alice, 0, ta, sizeof(ta)) != ESP_OK);
|
||||
serial_reset(); a = mint(&alice); b = mint(&bob);
|
||||
assert(web_serial_transport_revoke_sessions() == ESP_OK); absent(&a); absent(&b);
|
||||
assert(snapshot().initialized); assert(!host_lock_depth && closes > 0);
|
||||
@@ -153,6 +164,12 @@ int main(void) {
|
||||
assert(web_session_store_check_principal(a.view.id, &p, ¤t) != ESP_OK && !current);
|
||||
present(&a);
|
||||
}
|
||||
puts("PASS: serial/session binding, isolation, cleanup, failure fallback, races, Basic regression");
|
||||
serial_reset(); a = mint(&alice);
|
||||
char tickets[4][33];
|
||||
for (unsigned i = 0; i < 4; ++i) ticket_for(&a, tickets[i]);
|
||||
assert(web_serial_transport_mint_ticket(&alice, a.view.id, ta, sizeof(ta)) == ESP_ERR_NO_MEM);
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
assert(consume_ticket(tickets[i], a.view.id, &p, &consumed) == ESP_OK && consumed);
|
||||
puts("PASS: serial/session binding, isolation, cleanup, races, no Basic fallback or live ticket eviction");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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; });
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user