diff --git a/README.md b/README.md index abc3c3e..73908e1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one ## Development status -Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D plans integrated browser login/logout, an admin-shell terminal mode, typed settings, and contextual quick administration while preserving any browser-held serial writer lease across terminal-mode changes. Configurable STA-only mDNS naming as `sak-.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details. +Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D.3 browser login/logout is implemented, host-tested and build-verified; [M1 target/browser acceptance](docs/phase8d3_implementation.md) remains pending. Browser admin-shell mode, typed settings, and contextual administration remain planned, including preserving serial writer ownership across future terminal-mode changes. Configurable STA-only mDNS naming as `sak-.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details. ## Documentation @@ -77,7 +77,7 @@ Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with ## Security notes -The HTTPS interface uses a device-specific self-signed certificate and role-aware HTTP Basic authentication over TLS; there is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete. +The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete. ## License diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index b1295d7..e022ef5 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -98,19 +98,21 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li `web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade. -HTTP Basic authentication uses `user_database`. Before administrator bootstrap, the migrated role-`user` account is synchronized from the legacy credential, so that username/password can authenticate through the database; after bootstrap, the legacy blob is independent recovery material and is no longer consulted for authentication or synchronized into role-based accounts. Both `user` and `admin` roles currently receive the same web status/terminal experience; web administration is not implemented. +HTTPS login uses `user_database` and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. Before administrator bootstrap, the migrated role-`user` account is synchronized from the legacy credential, so that username/password can authenticate through the database; after bootstrap, the legacy blob is independent recovery material and is no longer consulted for authentication or synchronized into role-based accounts. Both `user` and `admin` roles currently receive the same web status/terminal experience; web administration is not implemented. -The boot-local Basic-authentication cache has four RAM entries and a five-minute sliding lifetime. It stores a keyed digest of the complete `Authorization` header rather than the raw header, and every hit revalidates principal currentness. Its current lack of locking relies on the single-HTTPD-owner execution model. +`web_cookie_auth` owns login/session/logout policy: four 120-second digest-only pre-login challenges, explicit same-origin bootstrap, five credential verifications per 60-second global window, and no live-record eviction. Host-only `__Host-` Secure/HttpOnly/SameSite=Strict cookies have absolute lifetimes. Login consumes a challenge, validates bounded JSON and issues a fresh session; logout invalidates only its originating session. Mutations require CSRF and strict canonical HTTPS Origin; serial upgrade requires matching cookie/Origin/ticket. Neither role has web administration yet. -Phase 8D.1 adds `web_session_store` primitives alongside Basic auth: four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. No HTTP handler issues cookie sessions yet. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Store-init failure cannot fail existing Basic HTTPS. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in `tests/web_session_store/`. +`web_session_store` holds four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. These are live cookie sessions in 8D.3, with no sliding renewal. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Authentication/store-init failure now gates HTTPS startup rather than falling back to Basic. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in `tests/web_session_store/`. -Phase 8D.2 binds serial tickets/slots to distinct originating web-session IDs; zero is reserved for the shipped Basic path. Trusted internal mint/upgrade callers supply the ID; bound checks also compare the session's copied principal, with no CSRF export. Mint/consume/admission/input and existing 250 ms owner checks validate session liveness/currentness. Session-specific transport revocation invalidates the store first, then clears matching tickets and flags matching reserved/active slots for existing HTTPD/broker cleanup. Account/global transport revocation now invalidates cookie records even if serial initialization failed; existing console mutation callers reach these hooks unchanged. A non-wrapping transport epoch cancels in-flight ticket publication across revocation and server detach/re-attach. Store/database checks remain authoritative if notification is missed. Bound browser paths remain dormant until 8D.3's atomic cookie/CSRF/Origin cutover; no public cookie route, capacity change or new task exists in 8D.2. +Serial tickets/slots bind to distinct originating web-session IDs; 8D.3 rejects zero instead of treating it as Basic. Trusted internal mint/upgrade callers supply the ID; bound checks also compare the session's copied principal, with no CSRF export. Mint/consume/admission/input and existing 250 ms owner checks validate session liveness/currentness. Session-specific transport revocation invalidates the store first, then clears matching tickets and flags matching reserved/active slots for existing HTTPD/broker cleanup. Account/global transport revocation now invalidates cookie records even if serial initialization failed; existing console mutation callers reach these hooks unchanged. A non-wrapping transport epoch cancels in-flight ticket publication across revocation and server detach/re-attach. Store/database checks remain authoritative if notification is missed. 8D.3 activates these checks for all browser routes, with five added authentication handlers (14 total), unchanged six HTTPS sockets and no new task. -A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; minting another evicts the live entry with the earliest expiry. Ticket issuance and upgrade also validate a supplied `Origin` against `https://`; absence of `Origin` is accepted for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts. +A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; expired/stale identities are reclaimed and live capacity is rejected with 503/Retry-After, not eviction. Ticket issuance and upgrade require `Origin` matching validated Host after host-case/default-port normalization; missing Origin fails even for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts. + +`web_httpd_adapter` is the sole private ESP-IDF 5.5.0 boundary. Its compile-time version guard requires review on upgrades. It validates NUL-separated parsed headers because public getters return only the first field, and rejects duplicates/ambiguous framing. The serial URI is registered as ordinary HTTP GET so cookie/ticket/principal/broker admission precedes explicit 101 and frame-handler installation; automatic IDF WebSocket routing would send 101 too early. Cleanup wipes consumed scratch but preserves right-aligned unread pending data. CMake compiles HTTPD logs above ERROR out to prevent header/ticket logging. No SDK patch or component copy exists. See `docs/phase8d3_implementation.md` for source verification, tests and pending on-wire checks. Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes. -`web_ui.c` contains authored index/application strings and response policy. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets. +`web_ui.c` contains authored index/application strings and response policy; it validates `/api/session` before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. `web_login_ui` is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets. ### SSH diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 7d48e79..34f3cdc 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -61,17 +61,17 @@ This is a semantic map, not a complete file inventory. Start here, then read the **Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients. - Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}` -- Security files: `src/web_security.{h,c}`; `src/web_session_store.{h,c}` contains dormant Phase 8D.1 cookie-session primitives, not active HTTP authentication. +- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`. - Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py` - Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation - Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI - Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant -- Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker` +- Flow: `browser -> HTTPS login/cookie session -> CSRF-protected ticket -> cookie/Origin/ticket admission -> WebSocket -> web transport -> broker` - Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets. -- Security constraints: Basic-auth cache hits still revalidate principal currentness; the browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect until Connect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change. -- Session-store boundary: admitted HTTPS start initializes four static records; failed start/accepted stop disables and wipes them. 8D.2 binds tickets/slots to non-reused session IDs (zero only for Basic); transport-specific cleanup and account/global revocation invalidate store records before socket cleanup. No cookie route yet. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode. -- 8D.3 preparation: `src/web_auth_parse.{c,h}` provides inert, allocation-free origin/cookie/login-JSON parsing; no HTTP caller yet. Test with `python3 tests/web_auth_parse/run.py`. These helpers do not authenticate or replace HTTP header/method/CSRF policy. -- 8D.3 login rendering: `src/web_login_ui.{c,h}` contains a standalone no-store login document and hash-bound script; no registered route or live caller. `python3 tests/web_login_ui/run.py` checks production C rendering, CSP and Node DOM/fetch doubles. Existing `web_ui.c` and generated assets are unchanged. +- Security constraints: Basic/cache removed; four absolute one-hour cookie sessions revalidate principal currentness. Four pre-login challenges (120 s), five credential attempts/60 s globally, no live session/challenge/ticket eviction. Origin/CSRF required for mutations; Origin/cookie/ticket before upgrade. Disconnect pauses reconnect but retains login; Sign out invalidates its session. Authored loader changes must update their hard-coded CSP hashes atomically. +- Session-store boundary: admitted HTTPS start initializes records; auth-init failure gates HTTPS. Failed start/accepted stop disables and wipes state. Tickets/slots require nonzero non-reused session IDs; session/account/global revocation invalidates store records before socket cleanup. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode. +- 8D.3 HTTP policy: `web_cookie_auth` owns public login/challenge/login POST/session/logout routes and protected-route checks; `web_auth_parse` handles bounded values/JSON. `web_httpd_adapter` alone reads private IDF 5.5.0 header scratch, rejects duplicate fields, defers 101 until transport admission and wipes consumed scratch while preserving right-aligned pending bytes. No SDK patch. `src/CMakeLists.txt` supplies private includes and compiles HTTPD warning/debug logs out. Test with `python3 tests/web_cookie_auth/run.py` and `python3 tests/web_auth_parse/run.py`. +- 8D.3 UI: `src/web_login_ui.{c,h}` serves standalone `/login`; `web_ui.c` validates session before serial connect/restore and handles logout/401 safely. Both scripts hash-bound, auth documents/app no-store. Tests: `python3 tests/web_login_ui/run.py` and `python3 tests/web_ui_session/run.py`. Live cutover host-tested/build-verified, M1 target gate pending: `docs/phase8d3_implementation.md`. - Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually. ## SSH diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 0c21718..553f88e 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,15 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.3 both-role target login confirmed / mixed-client evidence (2026-09-05):** User reports successful HTTPS user+admin login after Origin fix; previous login blocker is resolved. Settled internal/DMA/PSRAM free **71,204 / 63,448 / 8,247,744 B**. Mixed load free **33,868 / 26,112 / 8,089,060 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. 115200 baud, four broker clients (SSH sole writer, USB + two web observers), user/admin SSH active. No reported web transport or SSH I/O errors; 6 login attempts/3 invalid credentials/1 logout, zero security rejections. Two identical loaded heap samples are not a soak/leak or reserve proof. Full details/provenance in `docs/phase8d3_implementation.md`. **M1 validation in progress, not signed off.** +- **Admin SSH empty Enter fix (2026-09-05):** User's empty line was classified as UART0-restricted because `remote_command_allowed` required argc>0. Changed only helper classification to allow empty input through normal quiet IDF handling; currentness and physical-only commands remain protected. New `python3 tests/admin_ssh_policy/run.py` passes 15 cases using production helper/installed parser. Build passes **21.04 s**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash). Not uploaded or target-tested. Ask for empty Enter/normal-command smoke on next flash; no 8D.4 refactor. + +- **8D.3 Origin-null fix (2026-09-05), target retest pending:** User confirmed challenge200/login403 with `Origin: null`, same-origin Fetch Metadata and pre-login cookie; post-attempt counters show 0 password attempts and 7 security rejections. Root cause is non-CORS fetch POST under no-referrer. Login fetch now uses `mode:'cors'`; app helper uses cors for POST tickets/logout, unchanged GET mode. Same-origin credentials/fixed paths/redirect denial/CSP/no-referrer and strict server Origin/CSRF remain intact. Login CSP hash updated atomically. Both UI suites and cookie-policy suite pass; build **95,508 B RAM / 1,625,705 B flash**, **14.30 s** (+16 B flash). No upload; actual Firefox Origin header/login/serial/logout retest and M1 acceptance still pending. See implementation record; supersedes the speculative diagnosis below. + +- **8D.3 target login blocker (2026-09-05):** User supplied clean-boot/60-second settled telemetry, then reports both user/admin login rejected with the page's HTTP-403 message. Record in `docs/phase8d3_implementation.md`: internal free/min/largest 69,004/66,752/31,744 B; DMA 61,248/58,996/31,744 B; PSRAM 8,223,116/8,218,204/8,126,464 B; SSH stack minimum-free 18,464 B; no active sessions/broker clients, UART stopped. Boot counters precede login attempts; no post-attempt result yet. Exact flashed revision not supplied. Production login-renderer/CSP test passes. Browser warnings name other script hashes (possibly injected scripts), plus denied favicon/file URL; do not relax CSP based on these alone. Need failed endpoint/status/error code and nonsecret Origin/Sec-Fetch-Site. Investigation hypothesis: same-origin fetch mode plus no-referrer policy may serialize POST Origin as null; confirm wire headers before changing request policy. **M1 blocked, not signed off.** No corrective firmware change for this report yet. + +- **8D.3 live cutover implemented / host-tested / build-verified (2026-09-05), M1 target pending:** Resumed another agent's uncommitted completed server/browser implementation; preserved it, verified installed IDF header/upgrade semantics, and fixed right-aligned pending-buffer cleanup with an actual-IDF-reader regression. Cookie login/logout is live, Basic/cache removed. Final build **95,508 B RAM / 1,625,689 B flash** (+248 / +23,764 B versus 8D.2). See `docs/phase8d3_implementation.md`. This supersedes older inert/planned statements below. **Stop for M1 target/browser sign-off before 8D.4; numeric reserve gates remain open.** + - **8D.3 inert login renderer completed (2026-09-05):** User requested continuation after the parser split. Added standalone `web_login_ui.{c,h}` plus production-renderer/Node tests; no live route or Basic-auth change. Build and focused suites pass; 8D.3/M1 remains incomplete. Remaining work is the atomic server/app cutover, then mandatory browser/target validation. See active task below. - **8D.3 preparatory parser split (2026-09-05):** User requested continuation. Per the plan's 600–800-line scope review, selected inert private request parsing before the larger atomic login/logout cutover. `src/web_auth_parse.{c,h}` and focused host tests added; no live HTTP callers or authentication changes. See active task below. 8D.3/M1 is **not complete**; Basic remains active. Prior 8D.2 user sign-off stands; numeric reserves remain open. @@ -25,7 +34,7 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`: - Hardware characterization, serial service, session broker, USB CDC, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and local display/control are implemented and documented as target-hardware validated. - Phase 8A role-based user storage/UART0 administration and Phase 8B role-aware HTTPS/SSH authentication and targeted revocation are documented as target-hardware validated. - Phase 8C admin SSH is implemented in source, uses the shared `esp_console` registry, and has passed target-hardware validation. -- Phase 8D.1 is validated by user sign-off; 8D.2 serial/session binding is implemented, host-tested and build-verified with target regression pending. Browser login/logout and integrated web administration remain planned. Follow `docs/phase8d_plan.md`: one numbered chunk per request, target-validated login/logout (M1) before the browser admin shell (M2), then one typed-settings/control domain at a time (M3). Changing terminal modes must preserve the browser serial broker client and any writer lease. The roadmap retains the full end-state requirements. +- Phase 8D.0–8D.2 are validated by user sign-off; 8D.3 browser login/logout is implemented, host-tested and build-verified, with mandatory M1 target/browser validation pending. Browser administration remains planned. Follow `docs/phase8d_plan.md`: one numbered chunk per request, target-validated login/logout (M1) before the browser admin shell (M2), then one typed-settings/control domain at a time (M3). Changing terminal modes must preserve the browser serial broker client and any writer lease. The roadmap retains the full end-state requirements. - Security/production hardening, OTA, BLE evaluation, advanced networking, and optional filesystem features remain future roadmap work. - Reserved OTA, coredump, NVS-key, and storage partitions do not imply those runtime features are implemented. @@ -44,8 +53,8 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`: - Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned. - Current HTTPS has no web-based user administration and gives both roles the same status/terminal routes. -- Browser authentication still uses HTTP Basic; Phase 8D plans integrated login/logout sessions before exposing administrative browser routes. -- NVS encryption, secure boot/flash encryption review, authentication rate limiting, production certificate/provisioning policy, and OTA are not implemented. +- Browser authentication now uses cookie login/logout without Basic fallback. M1 target/browser validation gates any browser administrative routes. +- NVS encryption, secure boot/flash encryption review, production certificate/provisioning policy, and OTA are not implemented. HTTPS login has a bounded global five-verifications/60-second throttle, not comprehensive cross-transport DoS protection. ## Known inconsistencies @@ -58,9 +67,17 @@ These observations should be checked when touching the relevant area; they are n ## Items to verify in future work - Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection. -- If HTTPD concurrency configuration changes, add locking around the boot-local Basic-authentication cache. +- Re-audit the private HTTPD adapter on SDK changes (including same-version patches): parsed-header layout, right-aligned pending data, explicit handshake/frame installation and log suppression. Host tests do not establish real socket behavior. -## Active Task - Phase 8D.3 Inert Login Rendering +## Active Task - Phase 8D.3 Live Authentication Cutover + +- **Inherited implementation:** `web_cookie_auth.{c,h}`, `web_httpd_adapter.{c,h}`, parser optional-cookie validation, server/console/transport integration, browser app/session recovery, and focused suites. No Basic authorization/cache remains. Four one-hour sessions, four 120-second pre-login challenges, five password checks per 60-second window, four non-evicting 30-second tickets/two serial sockets. Fourteen URI slots, unchanged six HTTPS sockets and application task/stack/queue capacities. No generated assets or SDK files changed. +- **HTTPD decision:** Other agent chose an isolated **private-IDF adapter**, not the previously proposed SDK patch. Verified first-only header getters, append-only pointer-backed Set-Cookie (six-header successful login), auto-101-before-handler flow and private frame installation against installed 5.5.0. Serial URI uses ordinary GET until authenticated ticket/currentness/broker admission, then explicit handshake. Exact version guard requires re-audit on update; not a source-hash guarantee. HTTPD logs above ERROR compiled out to avoid secrets/ticket queries. Durable boundary recorded in architecture/design decisions and implementation record. +- **Fix in this continuation:** Pending HTTPD bytes are right-aligned. Inherited wipe preserved the wrong end, risking pipelined HTTP/early-frame corruption. Fixed consumed-prefix wipe and tested 0–128 pending lengths plus partial reads using extracted installed `httpd_recv_pending`. Kept all other inherited source work intact. +- **Validation:** `python3 tests/web_cookie_auth/run.py`, parser 268-case suite, login UI eight Node groups, serial app nine Node groups, and store `--serial` integration mode pass. Cookie suite also executes store tests and extracts installed header getter/setter/pending-reader functions; handshake/network/tasks remain doubled. Final `pio run` passed **17.62 s**, **95,508 B RAM / 1,625,689 B flash**; +248/+23,764 B versus 8D.2, +976/+25,716 B versus recorded 8D.0. Auth symbols 637 B before placement padding (including 576 B challenges); Basic cache/key removal offsets much of it. No runtime reserve or stack margin inferred. +- **Handoff:** `docs/phase8d3_implementation.md` contains route policy, source verification, exact test commands/limits, accounting and M1 checklist. No upload/erase/commit/branch operation or real target/browser execution. Obtain M1 sign-off before 8D.4. Prior 8D.0–8D.2 sign-offs stand. Numeric heap/largest-block/owner-stack reserve floors, real cookie/CSP/bfcache, pre-101 rejection, frame/pipelined data, five-cycle lifecycle checks, isolation/revocation/expiry latency and full-client-mix soak/cleanup evidence remain pending. + +## Previous Task - Phase 8D.3 Inert Login Rendering - **Scope:** Second permitted preparatory split after scope review of remaining challenge/throttle/HTTP-route/application work. Added only `src/web_login_ui.{c,h}`, CMake registration, `tests/web_login_ui/` and documentation. Standalone renderer has no live HTTP caller or URI registration; Basic cache/auth, existing app and serial protocol, capacities, task stacks and generated assets are unchanged. No upload, erase, commit or branch change. - **Behavior when integrated:** No fetch on page load; explicit Sign in obtains challenge with `X-Login-Bootstrap: 1`, then POSTs JSON with CSRF. Same-origin credentials/mode, no-store fetch, redirect rejection, 512-byte response bound and UTF-8 field/body limits. Generic safe-text errors, bounded Retry-After display/manual backoff, no automatic credential retry, fixed success navigation to `/`. Inputs disabled while pending; password fields/references cleared best-effort, attempt aborted on every exit, 15-second deadline, pagehide/pageshow generation guards. No localStorage/cookie access or logging; JavaScript/browser memory cannot be securely wiped. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index e2f61da..e54af83 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -40,7 +40,7 @@ Only constraints supported by implementation or current project documentation be **Consequence for future changes:** Preserve transport-slot generations and account-authentication generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation. -Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs in serial tickets/slots. Zero identifies only the existing Basic path until cutover. Session-specific cleanup must not become account-wide cleanup; account-name notification intentionally covers deletion/recreation. Invalidate cookie records before requesting transport cleanup, and retain authoritative session/principal checks when notification fails. The transport epoch cancels in-flight ticket publication without taking store and transport locks together. +Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs in serial tickets/slots. 8D.3 rejects zero IDs; Basic authentication/cache are removed. Session-specific cleanup must not become account-wide cleanup; account-name notification intentionally covers deletion/recreation. Invalidate cookie records before requesting transport cleanup, and retain authoritative session/principal checks when notification fails. The transport epoch cancels in-flight ticket publication without taking store and transport locks together. **Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}` @@ -94,6 +94,18 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs **Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c` +## Browser authentication has a narrow version-pinned HTTPD boundary + +**Decision:** 8D.3 uses `web_cookie_auth` plus digest-only session/challenge stores, mandatory Origin/CSRF mutations and no live session/challenge/ticket eviction. Four one-hour absolute sessions deliberately interrupt long serial connections at expiry. No Basic compatibility path remains. + +**Browser Origin serialization:** Authentication POST fetches use `mode: 'cors'` while retaining fixed same-origin URLs, `credentials: 'same-origin'`, redirect rejection and CSP `connect-src 'self'`. Under `no-referrer`, non-CORS POST mode can serialize Origin as `null` (confirmed in Firefox during M1 testing). Do not fix that by accepting null server-side or weakening CSP/referrer policy; no cross-origin server permission is added. + +**HTTPD boundary:** `web_httpd_adapter` alone includes private ESP-IDF 5.5.0 structures. Public request getters expose only the first field, so the adapter validates bounded parsed headers/rejects duplicates. `/ws/serial` is an ordinary GET until authenticated transport admission explicitly sends 101 and installs the frame handler; automatic HTTPD upgrades happen before URI handlers. Preserve right-aligned unread pending bytes when wiping request memory. Two Set-Cookie calls append pointer-backed fields, whose distinct buffers must survive through send. HTTPD logs above ERROR are compiled out to prevent header/ticket exposure. + +**Consequence:** The version guard is not a source-hash guarantee. Re-audit layout, scratch/pending ownership, logging and handshake/frame dispatch on SDK changes; do not scatter private accesses through application code or assume host doubles prove real socket behavior. No SDK patch is currently applied. See `docs/phase8d3_implementation.md` for verification and target gates. + +**Relevant files:** `src/web_cookie_auth.{c,h}`, `src/web_session_store.{c,h}`, `src/web_httpd_adapter.{c,h}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/CMakeLists.txt`. + ## Security material and configuration use bounded, versioned NVS records **Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, mDNS-hostname, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset. diff --git a/docs/phase8d3_implementation.md b/docs/phase8d3_implementation.md new file mode 100644 index 0000000..c8af245 --- /dev/null +++ b/docs/phase8d3_implementation.md @@ -0,0 +1,139 @@ +# Phase 8D.3 — Live browser authentication cutover + +Status (2026-09-05): **Implemented / host-tested / build-verified; both-role login and mixed-client target samples received, M1 validation in progress.** The Origin-null login blocker is resolved by user-confirmed successful login after the fix; no full M1 sign-off is implied. Resumed another agent's uncommitted implementation, verified it against installed ESP-IDF, fixed pending-buffer cleanup, and refreshed documentation. Earlier parser/login-renderer records are historical preparatory checkpoints. 8D.0–8D.2 user sign-offs stand; numeric reserve gates remain open. Do not start 8D.4 before M1 sign-off or an explicit user decision. + +## Successful post-fix target login and mixed-client sample (2026-09-05) + +The user explicitly reports successful HTTPS login as both `commander1024` (user) and `admin` after the Origin-mode correction. This supersedes the earlier login-blocker statements below. The following are user-provided sequential snapshots, not agent-executed tests or an atomic measurement. Exact flashed revision/hash and settled duration for this new sample were not supplied. + +| Heap | Settled boot free / minimum / largest (B) | Mixed load free / minimum / largest (B) | +|---|---|---| +| Internal 8-bit | 71,204 / 66,752 / 31,744 | 33,868 / 13,756 / 25,600 | +| Internal DMA | 63,448 / 58,996 / 31,744 | 26,112 / 6,000 / 25,600 | +| PSRAM | 8,247,744 / 8,245,836 / 8,126,464 | 8,089,060 / 8,072,744 / 7,995,392 | +| SSH stack minimum-free | 18,464 B (20,480 B configured) | 16,288 B | + +**Settled boot:** UART service stopped/owner idle at 115200 8N1/no flow; no broker clients or SSH sessions. USB initialized/attached but host closed/DTR false. HTTPS ready, sessions/challenges/tickets zero, all supplied web and SSH request/traffic/failure counters zero. mDNS initialized/announced as `sak-1024.local`, last error ESP_OK. The USB host's diagnostic 9600 coding does not configure UART1. + +**Mixed-client load:** UART running at 115200 8N1/no flow. Four broker clients: web user 8 observer, user SSH 9 **sole writer**, USB 10 observer, web admin 27 observer. Public-key user/admin SSH both active (2/2); admin SSH has no broker client. Two password-authenticated web serial sessions and two cookie sessions, no outstanding challenges/tickets. USB host open/DTR/RTS asserted; diagnostic line coding 115200. RX available/TX pending zero at the serial snapshot; all four displayed broker pending/event queues zero. mDNS remains announced without errors. + +- Two consecutive loaded `memory` samples are identical. This is short-term observation, **not** leak/soak/cleanup or reserve-floor validation. Lifetime minima include handshake/earlier activity; DMA overlaps internal heap. The **6,000 B DMA minimum** leaves runtime reserve analysis important even though current DMA free is 26,112 B. No stack fault/watchdog or memory exhaustion is reported. +- SSH: two successful handshakes/auth attempts, no handshake/auth/I/O/session-revocation failures. Stream RX 73 accepted, zero rejected, TX 31,941 B. Broker initial writer request was denied once; later writer snapshot shows SSH owns the lease (no inconsistency inferred from cumulative counters). Admin command-running/output-pending fields were sampled while executing status commands, not proof of a stuck dispatcher. +- Web: 97 protected requests, 96 authenticated and one auth failure; four roots, 80 status, four tickets, eight assets; zero response errors. Four tickets issued/consumed, zero rejected/expired. Four serial connects/two disconnects; two currently active. RX one accepted frame/14 B with no rejection; TX 495 binary frames/43,738 B and 15 control frames/1,255 B. Writer requests four, grants one, denials three, releases one; no revocations. No reported send/queue/protocol/service-start/broker/connection failures. +- Cookie auth: six password checks, three invalid-credential results, zero throttle/capacity/CSRF-or-Origin rejections, one logout, two active sessions. Successful both-role login is explicit user confirmation; the counters also show logout/reconnection activity but do not establish five cycles, account-isolation coverage or logout acknowledgement delivery. The three invalid-credential results and one protected-request auth failure are retained without attributing a cause. + +**Reported admin-SSH empty-line issue:** pressing Enter without text prints “Command is restricted to physical UART0.” Source trace identifies `remote_command_allowed()` classifying zero parsed arguments as a policy denial. Corrected that helper to allow empty input to reach IDF's normal quiet `ESP_ERR_INVALID_ARG` handling; existing current-admin/generation checks still run, and `user bootstrap`/`user recover` remain denied. No new dispatcher, route or 8D.4 refactor. `python3 tests/admin_ssh_policy/run.py` passes 15 policy cases with the production helper and installed IDF argument parser, including quoted restricted commands. `pio run` passes in **21.04 seconds**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash versus Origin fix). This SSH fix is **not yet target-tested** and was not present in the user's sample. No upload/erase/commit performed. + +Remaining: explicit M1 sign-off, repeated lifecycle/expiry/revocation/isolation and raw-client security checks, timed full-load soak and settled cleanup, numeric reserves and non-SSH owner stack margins. Do not reopen prior phase sign-offs or invent missing execution evidence. + +## First target sample and login blocker (historical, user-provided, 2026-09-05) + +After clean boot and 60 seconds settled, the user reports: + +| Heap | Free | Minimum-free | Largest block | +|---|---:|---:|---:| +| Internal 8-bit | 69,004 B | 66,752 B | 31,744 B | +| Internal DMA | 61,248 B | 58,996 B | 31,744 B | +| PSRAM | 8,223,116 B | 8,218,204 B | 8,126,464 B | + +SSH has 0/2 sessions, all supplied error/traffic counters zero, configured stack 20,480 B and minimum-free 18,464 B. HTTPS is running/ready with no lifecycle/response errors, cookie sessions 0/4 and challenges 0/4; eight protected requests were unauthenticated, with zero password-verification attempts, CSRF/origin rejections, logouts, tickets or serial sockets at this sample point. mDNS announces `sak-1024.local`. UART1 is stopped/owner idle, configured 115200 8N1/no flow; no broker clients. USB is initialized/attached but host-open/DTR false, broker disconnected. Its reported 9600 host coding does not configure UART1. These snapshots precede the reported login attempts; they do not establish post-attempt counters. Exact flashed revision/hash was not supplied. + +**M1 is blocked:** subsequent login attempts for a normal user and administrator both show “The sign-in challenge expired or the request was rejected. Please try again.” Browser console reports blocked inline scripts with two hashes different from the application's login script hash, denied favicon by default-src, and a denied file URL. No browser login, loaded-memory or full M1 acceptance is claimed. These errors do not establish memory exhaustion or invalid passwords. + +The production-renderer suite was rerun and the exact shipped inline-script hash still matches its CSP (`x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo=`). The displayed login message maps to HTTP 403 in the running script; the console's other hashes may be injected-script warnings, not a reason to broaden CSP. Request-stage/status and the bounded error code plus nonsecret Origin/Fetch Metadata are needed to isolate the rejection. No corrective firmware change has yet been made for this target report. + +## Confirmed Origin-null diagnosis and correction (2026-09-05) + +Follow-up user evidence: `/api/login-challenge` returns 200, `/api/login` returns 403 with request `Origin: null`, `Sec-Fetch-Site: same-origin`, and the pre-login cookie present. `web status` reports ready, zero sessions/challenges/tickets, **zero password-verification attempts** and **seven CSRF/origin rejections**. This confirms the rejection occurs before password authentication; it is not evidence of wrong credentials. No secret values were requested or retained. + +Cause: the authored fetch requests used non-CORS `mode: 'same-origin'` under `Referrer-Policy: no-referrer`; browser Origin-header serialization for these POSTs yields `null`. Corrected login fetch options to `mode: 'cors'` and the existing app API helper to use `cors` for POST (ticket/logout), retaining same-origin mode for app GETs. Fetch CORS mode is not permission for cross-origin service access: paths remain fixed same-origin endpoints, credentials remain `same-origin`, redirects remain rejected, CSP `connect-src 'self'` remains intact, and the server's strict Origin/CSRF checks/no-CORS-response policy are unchanged. The login script hash was updated atomically to `eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ=`; existing app loader hash is unchanged because only its external app script changed. + +Validation: login renderer/CSP eight Node groups, app nine Node groups, and cookie-policy suite all pass. Node guards assert CORS mode for every mutation (including logout), fixed endpoint destinations and no manually supplied Origin. They do not synthesize real browser Origin headers; Firefox/target retest is still required. `pio run` passed in **14.30 seconds**, **95,508 B RAM / 1,625,705 B flash** (RAM unchanged, flash +16 B versus the preceding live build). No upload/erase. Retest both roles, serial Connect/Disconnect/reconnect and Sign out; expect login POST Origin `https://sak-1024.local` (or the actual direct-IP origin), not null. M1 remains blocked until confirmed on target; other CSP warnings were not loosened or assumed resolved. + +## Delivered behavior + +- `web_cookie_auth.{c,h}` replaces Basic authentication/cache completely. Both roles use `/login` and the same serial/status application. No admin shell/settings routes were added. Previously cached Basic headers do not authorize a request. +- Four digest-only authenticated sessions retain the existing store's copied principal, canonical-origin binding, CSRF state, non-reused ID and one-hour absolute lifetime. Traffic/polling does not renew expiry. Failure to initialize authentication prevents HTTPS start; UART0/USB/SSH implementations remain unchanged. +- Four 120-second pre-login challenges contain only token/origin digests, CSRF state and expiry. Explicit login bootstrap requires `X-Login-Bootstrap: 1`; a matching live challenge is reused without extending its lifetime or resetting its cookie. Credential submissions consume the challenge, including wrong passwords. A global fixed window permits five password verifications per 60 seconds, including successes. Further attempts return 429 with Retry-After; no HTTPD sleep or per-IP/account table. +- Session and pre-login cookies use `__Host-sak-session` / `__Host-sak-prelogin`, `Secure; HttpOnly; SameSite=Strict; Path=/`, explicit Max-Age 3600/120 and no Domain. A consumed challenge expires its cookie; successful login additionally sets a fresh session cookie. Login with a current authenticated cookie returns 409; account switching requires logout. +- Mutations require canonical same-origin HTTPS Origin and CSRF; upgrade requires Origin and matching cookie/session/ticket. Host case and optional default port 443 normalize; non-443 ports, malformed authorities and IPv6 literals are rejected. Direct-IP and mDNS names remain distinct cookie origins. Cross-site/same-site Fetch Metadata requests are rejected (same-origin/none accepted); absent Origin is permitted only on read/bootstrap requests after Host validation. +- Exactly username/password string fields, maximum 512-byte login JSON, decoded 16/64-byte limits. Unknown/duplicate fields, NUL and malformed Unicode fail. Header/body/request scratch is wiped; rejected unread bodies close instead of invoking HTTPD's automatic body drain. Login reads have a three-second application deadline plus existing socket wait bounds. API authentication responses are at most 512 bytes; safe username JSON encoding is explicit. +- Full live session/challenge/ticket tables reject with 503 and Retry-After 5; serial-ticket earliest-expiry eviction is removed. Expired/stale tickets are reclaimed without database calls under the transport lock. Existing two serial sockets, one-writer broker model and binary protocol are unchanged. +- Logout invalidates its originating session before acknowledgement and requests only its ticket/socket cleanup. Account mutation/revocation continues to invalidate all affected account sessions, not unrelated accounts. Mint/consume/admission/input and existing periodic owner checks remain authoritative if notification fails. Zero session ID no longer falls back to Basic. +- Browser validates `/api/session` before initial connect/reconnect/restore; stores CSRF only in memory; adds Sign out and visible absolute expiry. 401 cancels work/closes local serial/navigates once to `/login`; explicit Disconnect still pauses reconnect without ending login. 403 mutation failures require explicit retry; capacity/backoff and network errors are not bad credentials. Lost logout response is reconciled with session status rather than claiming success. Pending fetch/socket callbacks are generation-guarded. Both authored inline scripts have exact CSP hashes; generated assets were not regenerated. + +### Route boundary + +| Route | Policy | +|---|---| +| GET `/login` | Public standalone no-store login page, no protected assets | +| GET `/api/login-challenge` | Validated Host, bootstrap header, Fetch Metadata and any supplied Origin | +| POST `/api/login` | Strict Origin, pre-login cookie/CSRF, bounded JSON and throttle | +| GET `/api/session` | Current cookie session; username/role/CSRF/remaining seconds only | +| POST `/api/logout` | Current session, strict Origin/CSRF, empty body | +| GET `/` | Current session; unauthenticated navigation gets 303 `/login` | +| GET five `/assets/` routes; GET `/api/status` | Current session; unauthenticated gets 401, not login HTML | +| POST `/api/ws-ticket` | Current session, strict Origin/CSRF, empty body | +| GET `/ws/serial?ticket=...` | Cookie/Origin authorization and ticket/principal/broker admission before explicit 101 | + +No CORS/preflight compatibility or Basic fallback. Query strings outside the exact serial-ticket route and wrong methods are rejected. Error routes have bounded no-store responses. The login document itself also rejects malformed/duplicate cookies; manually corrupted cookies may require clearing those site cookies, unlike ordinary expired well-formed cookies. + +## Verified HTTPD boundary and maintenance risk + +The delivered solution is **not the previously proposed SDK patch**. `web_httpd_adapter.{c,h}` alone includes installed HTTPD private headers. `src/CMakeLists.txt` supplies private include paths; the adapter fails compilation unless `ESP_IDF_VERSION == 5.5.0`. No installed SDK source was changed and no full component was vendored. + +Verified under `/home/mscholz/.platformio/packages/framework-espidf/components/esp_http_server/`: + +- `src/httpd_parse.c`, `httpd_req_get_hdr_value_len/str`: return the **first** matching header only. Parsed fields occupy NUL-separated scratch, not a raw CRLF block. Adapter walks that bounded storage and rejects **all duplicate field names**, case-insensitively, plus malformed fields, control characters, Transfer-Encoding and Expect. This is stricter than general HTTP acceptance, deliberately fail-closed. Public getters are called only after validation and with terminator capacity. +- `src/httpd_txrx.c`, `httpd_resp_set_hdr`: appends pointers, does not replace an earlier same-name field. Sending emits each entry; login retains its two cookie values until send returns. Success uses exactly **six of eight additional-header slots**. Tests extract the installed getters and append function rather than inventing their behavior. +- `src/httpd_uri.c`: routes marked `is_websocket=true` send 101 before their handler. The application's serial URI is deliberately registered as an ordinary GET. After cookie/Origin checks, transport consumes the matching ticket and completes currentness/broker admission, then adapter calls `httpd_ws_respond_server_handshake()` and installs the existing transport frame handler. Failed pre-admission never sends 101; handshake/admission failure uses existing reserved-slot/broker cleanup. Tests stub the handshake send: real on-wire integration remains a target gate. +- `src/httpd_txrx.c`, `httpd_unrecv/httpd_recv_pending`: pending bytes are **right-aligned**. The inherited adapter incorrectly wiped the unread suffix. This continuation fixes cleanup to wipe the consumed prefix while preserving unread bytes at the end, or wipe everything when closing. Regression exercises all 0–128 pending lengths and partial reads through the installed reader function. This prevents corruption of pipelined requests/early serial frames; it is not a claim of real socket execution. +- HTTPD DEBUG logs include headers, and URI warnings can include ticket queries. HTTPD is compiled with `LOG_LOCAL_LEVEL=ESP_LOG_ERROR`; ERROR sites were inspected for secret-bearing content. This deliberately removes HTTPD warning/debug diagnostics regardless of runtime log-level changes. Application count-only authentication telemetry remains available via `web status`/`web counters`. + +Private layout, frame dispatch and scratch ownership must be re-audited for an SDK update, including same-version local source patches (the guard checks the version, not source hashes). Do not distribute private-structure access into other application modules. Wiping reduces request lifetime, not all TLS/allocator/browser copies; do not claim resistance to RAM extraction. + +## Resource accounting + +Final `pio run` passed in **17.62 seconds** after the cleanup fix: + +| Metric | 8D.2 / preparatory baseline | Live 8D.3 | Increment | +|---|---:|---:|---:| +| Linked static RAM | 95,260 B | 95,508 B | +248 B | +| Reported program flash | 1,601,925 B | 1,625,689 B | +23,764 B | + +Cumulative versus recorded 8D.0 build (94,532 / 1,599,973 B): **+976 B RAM / +25,716 B flash**. These are linked sizes, not runtime headroom. + +- Target object symbol accounting: challenges **576 B (144 × 4)**, counters 32 B, lock 8 B, epoch 8 B, window 8 B, attempts 4 B, ready 1 B: **637 B before placement padding**. Removed Basic cache/key/readiness offset most of this; final link delta includes alignment/other changes. Existing session store remains present. +- No new application task, task-stack size change, module heap allocation, queue, TLS buffer, accepted socket or lwIP descriptor limit. HTTPD URI capacity rises **9 → 14**, with five additional dynamically allocated handler records; HTTPD error handlers use its existing table. Six HTTPS clients and two web serial slots remain unchanged. LRU purge remains enabled; retained-serial admission protection is still an M2 concern. +- Auth request locals include 513 B body/response scratch, 180 B cookie header, token/CSRF/canonical buffers, copied session/principal/challenge/credentials; cookie parsing has nested 1025 B header scratch. No task-stack reserve is inferred from source locals or static link size. Existing HTTPD stack is 10,240 B; real worst-case stack/TLS/PBKDF2/fragmentation measurements remain pending. +- Existing xterm/logo data unchanged. Login page and enlarged authored app are now actually linked; their dormant-preparation flash numbers were not their live cost. Header slots remain eight; login success six, login renderer five. + +## Executed validation + +All ran successfully in this continuation: + +```sh +python3 tests/web_cookie_auth/run.py +python3 tests/web_auth_parse/run.py +python3 tests/web_login_ui/run.py +python3 tests/web_ui_session/run.py +python3 tests/web_session_store/run.py --serial +pio run +``` + +- Cookie policy suite compiles production store/parser/policy/adapter with OpenSSL SHA-256 and deterministic database/HTTPD doubles. Covers fragmented reads, challenge reuse/consumption/expiry/capacity, session-specific logout, throttle, duplicate headers/cookies, methods/Origin/CSRF/Fetch Metadata, Basic denial, currentness, failures/stop race, cookie header budget and explicit upgrade state. Installed IDF getter/setter/pending-reader functions are extracted verbatim. It does **not** execute the full IDF parser, TLS, URI dispatcher, network handshake or real tasks. +- Parser suite: **268 cases**. Login renderer: production C failure/header checks and **eight Node groups**. Serial app: production C resource/header/CSP checks and **nine Node groups**. Node VM DOM/fetch doubles are not a real browser/CSP/bfcache test. +- Serial integration mode includes store public-API tests plus transport binding/isolation/races and no Basic/no live-ticket eviction. No sanitizer pass is claimed. + +No upload, erase, commit, branch change or target/browser exercise was performed. The preceding agent's changes were preserved except the focused pending-buffer fix/tests; its unrecorded executions are not evidence here. + +## M1 target acceptance handoff — stop before 8D.4 + +Use the complete [M1 contract/checklist](phase8d_baseline.md#minimal-m1-browser-contract-planned) and [user acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration). At minimum: + +1. Keep UART0 attached. Record flashed revision/configuration and settled-boot `memory`, `web status`, `web counters`, `broker clients`, `ssh status`. Confirm native USB and both SSH roles survive HTTPS stop/start and authentication failures. +2. Test both roles, fresh and previously Basic-authenticated profiles, direct IP and mDNS. Wrong credentials, refresh/back, expiry/reboot, sign out/account switch and lost logout response must remain usable. Verify actual secure cookie attributes and CSP; never include raw cookies/CSRF/tickets/passwords in shared evidence. +3. Five login/serial-disconnect/reconnect/logout cycles per role; five HTTPS stop/start cycles. Check session-specific logout across two sessions of the same account, and account password/role/key changes/deletion/recreation via UART0 while unrelated sessions survive. +4. Challenge/session/ticket capacity without eviction; bounded throttle and retry. Raw-client missing/malformed/duplicate Origin/Host/Cookie/CSRF/content-type/framing tests. Verify an unauthorized or mismatched-ticket upgrade gets **no 101**; validate actual frame routing, early/pending bytes and close cleanup after admitted upgrades. These are especially important for the private adapter. +5. Fifteen-minute full-client mix at 115200 baud (USB, two web serial clients, user SSH and admin SSH), then 60-second cleanup. Record internal/DMA/PSRAM free/minimum/largest block plus SSH stack margins at boot/login/serial/load/cleanup. Check binary integrity, writer isolation, drops and watchdogs. Measure the planned ≤1-second expiry/revocation detection target under contention separately from socket-close delivery. +6. Numeric reserve floors and non-SSH owner-stack instrumentation remain pending. Obtain explicit M1 sign-off before adding the browser admin shell. Do not equate host tests/build success with target acceptance. diff --git a/docs/phase8d_baseline.md b/docs/phase8d_baseline.md index 83fbe13..1ea68ba 100644 --- a/docs/phase8d_baseline.md +++ b/docs/phase8d_baseline.md @@ -1,10 +1,10 @@ # Phase 8D.0 — Baseline and M1 browser contract -Status: **8D.0 and 8D.1 validated by user sign-off on 2026-09-05.** Documentation/source audit, builds and target runtime samples are recorded. Numeric reserve floors and future incremental budgets remain open engineering gates, not blockers to these user-approved closures. The M1 browser contract below remains planned, not implemented authentication. See [execution plan](phase8d_plan.md) and [acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration). +Status: **8D.0 and 8D.1 validated by user sign-off on 2026-09-05.** Documentation/source audit, builds and target runtime samples are recorded. Numeric reserve floors and future incremental budgets remain open engineering gates, not blockers to these user-approved closures. The M1 browser contract below was established during baseline planning; the subsequent [8D.3 live cutover record](phase8d3_implementation.md) now documents implemented/host-tested/build-verified authentication, with target/browser acceptance pending. Baseline measurements and source-behavior descriptions here remain historical, not measurements of the live cutover. See [execution plan](phase8d_plan.md) and [acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration). ## Validation sign-off -The subsequent [8D.2 implementation record](phase8d2_implementation.md) contains its separate build/resource accounting and pending target checklist. The baseline and 8D.1 measurements below remain historical evidence, not 8D.2 target validation. +The subsequent [8D.2 implementation record](phase8d2_implementation.md) contains its separate build/resource accounting, target samples and user sign-off. The baseline and 8D.1 measurements below remain historical evidence, not 8D.2 target validation. - The user explicitly marked **8D.0 validated** and identified the tested firmware with the latest checked-in project state, resolved at sign-off to Git revision **`d8999cd4a96e477fabd392ced02d810c3cd22d0f`**. This is user-confirmed source provenance, not an independently read-back device binary hash. The earlier reproducible build revision and SHA-256 table remain historical build evidence, not newly generated hashes for this revision. - The user attributes the SSH I/O errors to testing at **out-of-spec 460400 baud**. Preserve that exact reported rate separately from the transcripts' **460800-baud UART configuration**; the differing rate may describe the test setup, and no firmware baud-support change or independently reproduced diagnosis is implied. The session-revocation counter remains recorded without a separately supplied causal explanation. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index a9d1840..3173307 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,6 +1,6 @@ # Phase 8D — Incremental web administration plan -Status: **8D.0 through 8D.2 validated by user sign-off on 2026-09-05. 8D.2 implemented, host-tested and build-verified. Reserve budgets remain pending. 8D.3 has inert request-parser and login-renderer preparatory splits; its live cutover and 8D.4–8D.22 remain planned.** See the [8D.0 baseline and M1 contract](phase8d_baseline.md); no browser authentication cutover is claimed. +Status: **8D.0 through 8D.2 validated by user sign-off on 2026-09-05. 8D.3 live login/logout cutover implemented, host-tested and build-verified; mandatory M1 target/browser validation and numeric reserve gates remain pending. 8D.4–8D.22 remain planned.** See the [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md). This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist. @@ -90,6 +90,8 @@ After each runtime-changing chunk: build; boot and read UART0 status/`memory`; l If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering or private request-parsing helpers. Do not split the live security cutover into an insecure intermediate deployment. +**Live cutover checkpoint (2026-09-05):** [Implementation, HTTPD boundary, resource accounting and M1 handoff](phase8d3_implementation.md). Cookie login/logout replaces Basic for app/status/ticket routes; explicit pre-101 admission and strict header/Origin/CSRF policy use an isolated version-checked private IDF adapter, **not an SDK patch**. Resumed another agent's implementation and fixed pending-buffer wiping to preserve right-aligned unread data. All five focused suites and `pio run` pass. Final **95,508 B RAM / 1,625,689 B flash**, +248/+23,764 B versus 8D.2. No hardware/browser execution or reserve-floor approval. **Stop for mandatory M1 acceptance before 8D.4.** The following preparatory records are historical, superseded for current implementation status. + **Preparatory split (2026-09-05):** Scope review selected private request parsing first; the complete challenge/throttle/route/browser/test change exceeds the authored-line work-unit target. Added allocation-free `src/web_auth_parse.{c,h}` with no live HTTP callers: bounded canonical same-origin comparison, unique cookie extraction and strict UTF-8 login JSON decoding. `python3 tests/web_auth_parse/run.py` passes **268 cases** against production C; both existing session-store host modes pass. Final `pio run` passes in **7.50 seconds** and reports **95,260 B RAM / 1,601,925 B flash**, unchanged from 8D.2 because helpers are not live linked paths. No route/task/socket/stack-size/asset changes. No target/browser validation, runtime reserve approval or M1 completion is implied. Continue within **8D.3**, with the full atomic cutover still pending; see `docs/agent/current-state.md` for exact integration obligations. **Login-renderer preparatory split (2026-09-05):** Added `src/web_login_ui.{c,h}` with no live caller/route, leaving Basic and existing serial UI unchanged. Standalone 7,387-byte HTML plus terminator has no protected asset dependencies, five security headers including no-store and exact script-hash CSP. Explicit-only challenge/login flow, bounded request/response handling, safe errors/manual backoff, disabled pending inputs, best-effort password/reference cleanup and generation-safe page-exit cancellation. Review fixes abort every attempt on exit (including unread error bodies) and clear re-entered passwords. `python3 tests/web_login_ui/run.py` passes production C rendering/failure checks, exact CSP hash and eight Node VM groups; parser and both session test modes also pass. Final `pio run` passes in **8.25 seconds**, unchanged **95,260 B RAM / 1,601,925 B flash**: unused renderer costs are not live-linked/runtime costs yet. No new task/socket/route/stack-size/module heap or generated-asset change. No real-browser/HTTPD/hardware validation or M1 completion. **Next is the atomic live 8D.3 cutover using both prepared pieces**, followed by the mandatory M1 target gate; no additional login-rendering split is needed. @@ -163,11 +165,11 @@ Update the roadmap and user/command documentation to distinguish completed featu ## Progress and next-request template -Progress: **8D.0 through 8D.2 validated by user sign-off; 8D.2 implemented / host-tested / build-verified. Reserve gates pending. 8D.3 inert parser and login-renderer preparation implemented; live cutover and 8D.4–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks. +Progress: **8D.0 through 8D.2 validated by user sign-off. 8D.3 live cutover implemented / host-tested / build-verified, awaiting M1 target/browser acceptance and numeric reserves. 8D.4–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks. Suggested next request: -> Continue Phase 8D.3 with the atomic live login/logout cutover; request parsing and standalone login rendering are prepared and host-tested. Preserve 8D.2 sign-off and open numeric reserve gates, and stop for M1 target/browser validation before 8D.4. +> Validate the live Phase 8D.3/M1 cutover on target using its implementation record. Record browser/transport/lifecycle and memory evidence, resolve any failures, and obtain explicit M1 sign-off before beginning 8D.4. Preserve existing phase sign-offs and open numeric reserve gates. For later chunks: diff --git a/docs/roadmap.md b/docs/roadmap.md index 8b8b50d..46ade1d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -199,7 +199,7 @@ Implementation sequence: - Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled. - Target-hardware validation passed for route separation, history/Tab editing, interactive visible/hidden prompts, output/backpressure, generated and entered user/password/key management including the longest ECDSA P-256 import, ping event routing, deferred reboot/SSH lifecycle drain behavior, bootstrap/recovery rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation. Stress at 460800 baud with SSH and WebSocket clients caused substantial expected packet drops and slower display controls, but did not exhaust memory or require lowering the supported baud-rate range. 4. **Phase 8D — Integrated web administration — Planned, staged delivery** - - **Implementation checkpoint:** 8D.0/8D.1 validated by user sign-off; [8D.2 serial/session binding](phase8d2_implementation.md) implemented, host-tested and build-verified, with target regression and numeric reserve gates pending. Basic remains the public authentication path; browser login/logout and later milestones below are not yet implemented. + - **Implementation checkpoint:** 8D.0–8D.2 validated by user sign-off. [8D.3 live login/logout](phase8d3_implementation.md) implemented, host-tested and build-verified with cookie sessions and no Basic fallback; mandatory M1 target/browser validation and numeric reserves remain pending. Browser admin shell/settings and later milestones below remain planned. Stop before 8D.4 until M1 sign-off. - **Execution plan:** [Phase 8D incremental plan](phase8d_plan.md). Implement one numbered chunk per request, with a build, focused regression checks, memory accounting, and a handoff before stopping. The requirements below describe the final scope, not one implementation task. - **Milestones:** 8D.0–8D.3 establish a measured baseline and reliable login/logout with the existing serial UI; 8D.4–8D.7 add the shared browser admin shell and verify retained serial ownership; 8D.8–8D.21 add typed settings and contextual controls one domain at a time; 8D.22 performs final integration acceptance. Login and runtime-memory target validation gate the first two milestones; do not defer them until the entire phase is implemented. No wholesale import of the rolled-back experimental implementation. - Begin with integrated authentication: replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs. diff --git a/docs/user_administration_tests.md b/docs/user_administration_tests.md index d8ee8ac..fcada69 100644 --- a/docs/user_administration_tests.md +++ b/docs/user_administration_tests.md @@ -168,7 +168,7 @@ Finally, issue commands concurrently from UART0 and admin SSH, including `user l ## Planned Phase 8D integrated web administration -These are acceptance requirements for the planned implementation, not tests that have passed yet. Execute them incrementally using the [Phase 8D work-unit plan](phase8d_plan.md), not only at the end of the phase. +These are acceptance requirements, not tests that have passed yet. The [8D.3 live login/logout implementation](phase8d3_implementation.md) is host-tested/build-verified and awaits M1 target/browser execution; browser administration remains planned. Execute them incrementally using the [Phase 8D work-unit plan](phase8d_plan.md), not only at the end of the phase. Validation checkpoints: diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3fd6ad7..d37f798 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,6 +36,8 @@ idf_component_register( "web_server.c" "web_session_store.c" "web_auth_parse.c" + "web_httpd_adapter.c" + "web_cookie_auth.c" "web_login_ui.c" "web_console.c" "wifi_config.c" @@ -70,6 +72,16 @@ idf_component_register( wolfssl__wolfssl ) +# Only web_httpd_adapter.c uses this private, version-checked boundary. +target_include_directories(${COMPONENT_LIB} PRIVATE + "$ENV{IDF_PATH}/components/esp_http_server/src" + "$ENV{IDF_PATH}/components/esp_http_server/src/port/esp32") + +# HTTPD debug logs include header values; URI warnings include ticket queries. +# Compile those out, independently of runtime log-level changes. +idf_component_get_property(httpd_lib esp_http_server COMPONENT_LIB) +target_compile_definitions(${httpd_lib} PRIVATE LOG_LOCAL_LEVEL=ESP_LOG_ERROR) + # Public wolfSSH headers include wolfCrypt configuration from user_settings.h. target_compile_definitions(${COMPONENT_LIB} PRIVATE WOLFSSL_USER_SETTINGS diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 72755a3..94770ef 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -439,8 +439,9 @@ static bool remote_command_allowed(const admin_request_t *request) char *argv[ADMIN_SSH_CONSOLE_MAX_ARGUMENTS] = {0}; /* Use exactly the same quote/escape parser as esp_console_run(). */ size_t argc = esp_console_split_argv(copy, argv, ADMIN_SSH_CONSOLE_MAX_ARGUMENTS); - bool allowed = argc > 0U; - if (allowed && strcmp(argv[0], "user") == 0 && argc >= 2U && + /* Empty input is handled quietly by esp_console_run(), not UART0 policy. */ + bool allowed = true; + if (argc >= 2U && strcmp(argv[0], "user") == 0 && (strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) { allowed = false; } diff --git a/src/web_auth_parse.c b/src/web_auth_parse.c index 7ac8b6d..f157a2b 100644 --- a/src/web_auth_parse.c +++ b/src/web_auth_parse.c @@ -61,9 +61,11 @@ static bool cookie_name_char(unsigned char c) return alnum_ascii(c) || (c && strchr("!#$%&'*+-.^_`|~", c)); } -bool web_auth_parse_cookie(const char *header, size_t length, const char *name, - char token[WEB_AUTH_TOKEN_LENGTH + 1U]) +bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name, + char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present) { + if (!present) return false; + *present = false; if (!token) return false; memset(token, 0, WEB_AUTH_TOKEN_LENGTH + 1U); if (!header || !name || !*name || !length || length > WEB_AUTH_COOKIE_HEADER_MAX) @@ -92,7 +94,15 @@ bool web_auth_parse_cookie(const char *header, size_t length, const char *name, if (pos < length && ++pos == length) return false; } if (found) memcpy(token, header + selected, WEB_AUTH_TOKEN_LENGTH); - return found; + *present = found; + return true; +} + +bool web_auth_parse_cookie(const char *header, size_t length, const char *name, + char token[WEB_AUTH_TOKEN_LENGTH + 1U]) +{ + bool present = false; + return web_auth_parse_optional_cookie(header, length, name, token, &present) && present; } typedef struct { const uint8_t *data; size_t length; size_t pos; } json_cursor_t; diff --git a/src/web_auth_parse.h b/src/web_auth_parse.h index d07bb0b..b5a1e20 100644 --- a/src/web_auth_parse.h +++ b/src/web_auth_parse.h @@ -36,6 +36,10 @@ bool web_auth_parse_origin(const char *host, size_t host_length, * name is a trusted, nonempty C string. Output is sensitive: wipe after use. */ bool web_auth_parse_cookie(const char *header, size_t length, const char *name, char token[WEB_AUTH_TOKEN_LENGTH + 1U]); +/* As above, but a missing selected cookie is valid with present=false. This + * lets HTTP policy distinguish absence from malformed/ambiguous cookies. */ +bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name, + char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present); /* Exactly username/password string fields, either order. JSON escapes and valid * UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected. * Database credential policy remains authoritative. Caller must wipe BOTH the diff --git a/src/web_console.c b/src/web_console.c index 8fd1c04..4ec44d5 100644 --- a/src/web_console.c +++ b/src/web_console.c @@ -14,6 +14,7 @@ #include "web_security.h" #include "web_serial_transport.h" #include "web_server.h" +#include "web_cookie_auth.h" static void print_usage(void) { @@ -52,13 +53,24 @@ static int show_status(void) (unsigned int)snapshot.port, esp_err_to_name(snapshot.last_error)); if (users_error == ESP_OK) { - printf("Authentication: HTTP Basic over TLS via user database, users=%u admins=%u\n", + printf("Authentication: HTTPS cookie sessions via user database, users=%u admins=%u\n", (unsigned int)users.user_count, (unsigned int)users.admin_count); } else { printf("Authentication database unavailable: %s; use 'user recover --force'.\n", esp_err_to_name(users_error)); } printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n"); + printf("Authentication routes: GET /login, GET /api/login-challenge, POST /api/login, GET /api/session, POST /api/logout\n"); + web_cookie_auth_snapshot_t auth; + web_cookie_auth_get_snapshot(&auth); + web_session_store_snapshot_t sessions; + if (web_session_store_get_snapshot(&sessions) == ESP_OK) + printf("Cookie authentication: ready=%s sessions=%" PRIu32 "/4 challenges=%" PRIu32 "/4\n", + auth.ready ? "yes" : "no", sessions.active, auth.active_challenges); + printf("Login attempts=%" PRIu32 " invalid-credentials=%" PRIu32 " throttled=%" PRIu32 + " auth-capacity-rejections=%" PRIu32 " CSRF/origin-rejections=%" PRIu32 " logouts=%" PRIu32 "\n", + auth.login_attempts, auth.login_failures, auth.throttled, auth.capacity_rejections, + auth.security_rejections, auth.logouts); web_serial_transport_snapshot_t transport; esp_err_t transport_error = web_serial_transport_get_snapshot(&transport); diff --git a/src/web_cookie_auth.c b/src/web_cookie_auth.c new file mode 100644 index 0000000..45e4711 --- /dev/null +++ b/src/web_cookie_auth.c @@ -0,0 +1,417 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_cookie_auth.h" +#include +#include +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "mbedtls/sha256.h" +#include "secure_random.h" +#include "web_auth_parse.h" +#include "web_httpd_adapter.h" +#include "web_login_ui.h" +#include "web_serial_transport.h" + +#define SESSION_COOKIE "__Host-sak-session" +#define PRELOGIN_COOKIE "__Host-sak-prelogin" +#define COOKIE_FLAGS "; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=" +#define CHALLENGE_US 120000000LL +#define WINDOW_US 60000000LL + +typedef struct { + int64_t expiry; + uint8_t token_digest[32]; + uint8_t origin_digest[32]; + char csrf[65]; +} challenge_t; + +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static challenge_t s_challenges[4]; +static bool s_ready; +static uint64_t s_epoch; +static int64_t s_window; +static unsigned s_attempts; +static web_cookie_auth_snapshot_t s_counts; + +static bool equal(const void *a, const void *b, size_t size) +{ + const uint8_t *x = a, *y = b; + unsigned difference = 0; + for (size_t i = 0; i < size; ++i) difference |= x[i] ^ y[i]; + return difference == 0; +} + +static bool header(httpd_req_t *r, const char *name, char *out, size_t size) +{ + size_t length = httpd_req_get_hdr_value_len(r, name); + out[0] = 0; + return length < size && httpd_req_get_hdr_value_str(r, name, out, size) == ESP_OK; +} + +static bool origin(httpd_req_t *r, bool required, char canonical[129]) +{ + char host[129] = {0}, supplied[137] = {0}, site[16] = {0}; + if (!header(r, "Host", host, sizeof(host))) return false; + if (header(r, "Sec-Fetch-Site", site, sizeof(site))) { + if (strcmp(site, "same-origin") && strcmp(site, "none")) return false; + } else if (httpd_req_get_hdr_value_len(r, "Sec-Fetch-Site")) return false; + if (!header(r, "Origin", supplied, sizeof(supplied))) { + if (required || httpd_req_get_hdr_value_len(r, "Origin")) return false; + int length = snprintf(supplied, sizeof(supplied), "https://%s", host); + if (length < 0 || (size_t)length >= sizeof(supplied)) return false; + } + return web_auth_parse_origin(host, strlen(host), supplied, strlen(supplied), canonical); +} + +static bool cookie(httpd_req_t *r, const char *name, char token[65]) +{ + char cookies[1025] = {0}; + bool valid = header(r, "Cookie", cookies, sizeof(cookies)) && + web_auth_parse_cookie(cookies, strlen(cookies), name, token); + secure_wipe(cookies, sizeof(cookies)); + return valid; +} + +static bool cookies_valid(httpd_req_t *r) +{ + char cookies[1025] = {0}, token[65] = {0}; + bool present; + esp_err_t error = httpd_req_get_hdr_value_str(r, "Cookie", cookies, sizeof(cookies)); + bool valid = error == ESP_ERR_NOT_FOUND || + (error == ESP_OK && httpd_req_get_hdr_value_len(r, "Cookie") < sizeof(cookies) && + web_auth_parse_optional_cookie(cookies, strlen(cookies), SESSION_COOKIE, token, &present) && + web_auth_parse_optional_cookie(cookies, strlen(cookies), PRELOGIN_COOKIE, token, &present)); + secure_wipe(cookies, sizeof(cookies)); + secure_wipe(token, sizeof(token)); + return valid; +} + +static esp_err_t response(httpd_req_t *r, const char *status, const char *body) +{ + esp_err_t error = httpd_resp_set_status(r, status); + if (error == ESP_OK) error = httpd_resp_set_type(r, "application/json; charset=utf-8"); + if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Cache-Control", "no-store"); + if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Content-Type-Options", "nosniff"); + if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Referrer-Policy", "no-referrer"); + if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Frame-Options", "DENY"); + if (error == ESP_OK) error = httpd_resp_sendstr(r, body); + /* Never let HTTPD drain an attacker-controlled rejected request body. */ + return web_httpd_unread_body(r) ? ESP_FAIL : error; +} + +static esp_err_t failure(httpd_req_t *r, const char *status, const char *code) +{ + bool security = !strcmp(status, "403 Forbidden"); + bool credentials = !strcmp(code, "invalid_credentials"); + bool throttled = !strcmp(code, "throttled"); + bool full = !strcmp(code, "capacity"); + taskENTER_CRITICAL(&s_lock); + s_counts.security_rejections += security; + s_counts.login_failures += credentials; + s_counts.throttled += throttled; + s_counts.capacity_rejections += full; + taskEXIT_CRITICAL(&s_lock); + char body[80]; + snprintf(body, sizeof(body), "{\"error\":\"%s\"}", code); + return response(r, status, body); +} + +static esp_err_t capacity(httpd_req_t *r) +{ + if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL; + return failure(r, "503 Service Unavailable", "capacity"); +} + +esp_err_t web_cookie_auth_start(void) +{ + esp_err_t error = web_session_store_init(); + taskENTER_CRITICAL(&s_lock); + if (error == ESP_OK && s_epoch != UINT64_MAX) { + ++s_epoch; + secure_wipe(s_challenges, sizeof(s_challenges)); + s_window = 0; + s_attempts = 0; + s_ready = true; + } else { + s_ready = false; + error = ESP_ERR_INVALID_STATE; + } + taskEXIT_CRITICAL(&s_lock); + return error; +} + +void web_cookie_auth_stop(void) +{ + taskENTER_CRITICAL(&s_lock); + s_ready = false; + if (s_epoch != UINT64_MAX) ++s_epoch; + secure_wipe(s_challenges, sizeof(s_challenges)); + s_window = 0; + s_attempts = 0; + taskEXIT_CRITICAL(&s_lock); + web_session_store_stop(); +} + +void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot) +{ + if (!snapshot) return; + int64_t now = esp_timer_get_time(); + taskENTER_CRITICAL(&s_lock); + *snapshot = s_counts; + snapshot->ready = s_ready; + snapshot->active_challenges = 0; + for (unsigned i = 0; i < 4; ++i) { + if (s_challenges[i].expiry <= now) secure_wipe(&s_challenges[i], sizeof(s_challenges[i])); + else ++snapshot->active_challenges; + } + taskEXIT_CRITICAL(&s_lock); +} + +void web_cookie_auth_clear_counters(void) +{ + taskENTER_CRITICAL(&s_lock); + memset(&s_counts, 0, sizeof(s_counts)); + taskEXIT_CRITICAL(&s_lock); +} + +esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade, + web_session_view_t *view, bool *allowed) +{ + char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0}; + *allowed = false; + memset(view, 0, sizeof(*view)); + if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) || + r->content_len || r->method != (mutation ? HTTP_POST : HTTP_GET)) + return failure(r, "400 Bad Request", "invalid_request"); + if (!origin(r, mutation || upgrade, canonical)) + return failure(r, "403 Forbidden", "origin"); + taskENTER_CRITICAL(&s_lock); + bool ready = s_ready; + taskEXIT_CRITICAL(&s_lock); + if (!ready) return failure(r, "503 Service Unavailable", "unavailable"); + esp_err_t error = ESP_ERR_NOT_FOUND; + if (cookie(r, SESSION_COOKIE, token)) + error = web_session_store_lookup(token, strlen(token), canonical, strlen(canonical), view); + secure_wipe(token, sizeof(token)); + if (error != ESP_OK) { + if (error != ESP_ERR_NOT_FOUND) + return failure(r, "503 Service Unavailable", "unavailable"); + if (!strcmp(r->uri, "/")) { + if (httpd_resp_set_hdr(r, "Location", "/login") != ESP_OK) return ESP_FAIL; + return response(r, "303 See Other", ""); + } + return failure(r, "401 Unauthorized", "authentication_required"); + } + if (mutation && (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || + strlen(csrf) != 64U || !equal(csrf, view->csrf, 64U))) { + secure_wipe(csrf, sizeof(csrf)); + secure_wipe(view, sizeof(*view)); + return failure(r, "403 Forbidden", "csrf"); + } + secure_wipe(csrf, sizeof(csrf)); + *allowed = true; + return ESP_OK; +} + +static bool secret(char out[65]) +{ + uint8_t bytes[32]; + bool ok = secure_random_fill(bytes, sizeof(bytes)) == ESP_OK; + if (ok) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < sizeof(bytes); ++i) { + out[2*i] = hex[bytes[i] >> 4]; + out[2*i+1] = hex[bytes[i] & 15]; + } + out[64] = 0; + } + secure_wipe(bytes, sizeof(bytes)); + return ok; +} + +esp_err_t web_cookie_auth_handler(httpd_req_t *r) +{ + bool login = !strcmp(r->uri, "/api/login"); + bool bootstrap = !strcmp(r->uri, "/api/login-challenge"); + bool logout = !strcmp(r->uri, "/api/logout"); + bool document = !strcmp(r->uri, "/login"); + web_session_view_t view = {0}; + char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0}; + char set_cookie[180] = {0}, body[513] = {0}; + web_auth_credentials_t credentials = {0}; + challenge_t candidate = {0}; + uint8_t digest[32] = {0}, origin_digest[32] = {0}; + esp_err_t result = ESP_FAIL; + const char *status = "400 Bad Request", *code = "invalid_request"; + bool consumed = false, allowed = false; + bool challenge_published = false; + uint64_t epoch; + int selected = -1; + int64_t now = esp_timer_get_time(); + taskENTER_CRITICAL(&s_lock); + bool ready = s_ready; + epoch = s_epoch; + taskEXIT_CRITICAL(&s_lock); + if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; } + if (!web_httpd_headers_valid(r) || !cookies_valid(r) || strchr(r->uri, '?') || + r->method != ((login || logout) ? HTTP_POST : HTTP_GET) || + (!login && r->content_len)) goto deny; + if (document) { result = web_login_ui_send_response(r); goto cleanup; } + if (!login && !bootstrap) { + result = web_cookie_auth_require(r, logout, false, &view, &allowed); + if (!allowed) goto cleanup; + if (logout) { + web_serial_transport_revoke_web_session(view.id); + taskENTER_CRITICAL(&s_lock); + ++s_counts.logouts; + taskEXIT_CRITICAL(&s_lock); + result = httpd_resp_set_hdr(r, "Set-Cookie", SESSION_COOKIE "=" COOKIE_FLAGS "0"); + if (result != ESP_OK) goto cleanup; + result = response(r, "204 No Content", ""); + } else { + /* Database usernames are restricted ASCII; encode nevertheless. */ + char username[97] = {0}; + size_t used = 0; + for (size_t i = 0; i < view.principal.username_length && i < 16; ++i) + used += (size_t)snprintf(username + used, sizeof(username) - used, + "\\u%04x", (unsigned char)view.principal.username[i]); + int64_t remaining = (view.expires_at_us - esp_timer_get_time()) / 1000000LL; + snprintf(body, sizeof(body), "{\"username\":\"%s\",\"role\":\"%s\",\"csrf\":\"%s\",\"expires_in\":%lld}", + username, view.principal.role == USER_ROLE_ADMIN ? "admin" : "user", view.csrf, + (long long)(remaining > 0 ? remaining : 0)); + result = response(r, "200 OK", body); + } + goto cleanup; + } + if (!origin(r, login, canonical)) { status = "403 Forbidden"; code = "origin"; goto deny; } + if (mbedtls_sha256((const uint8_t *)canonical, strlen(canonical), origin_digest, 0)) goto deny; + if (bootstrap) { + char flag[2]; + if (!header(r, "X-Login-Bootstrap", flag, sizeof(flag)) || strcmp(flag, "1")) { + status = "403 Forbidden"; code = "csrf"; goto deny; + } + } else { + char type[40]; + if (!header(r, "Content-Type", type, sizeof(type)) || + (strcmp(type, "application/json") && strcmp(type, "application/json; charset=utf-8"))) { + status = "415 Unsupported Media Type"; code = "content_type"; goto deny; + } + if (!r->content_len || r->content_len > 512U) { + status = "413 Payload Too Large"; code = "body_size"; goto deny; + } + if (cookie(r, SESSION_COOKIE, token) && + web_session_store_lookup(token, 64, canonical, strlen(canonical), &view) == ESP_OK) { + status = "409 Conflict"; code = "already_authenticated"; goto deny; + } + if (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64) { + status = "403 Forbidden"; code = "csrf"; goto deny; + } + } + bool has_cookie = cookie(r, PRELOGIN_COOKIE, token); + if (has_cookie && mbedtls_sha256((const uint8_t *)token, 64, digest, 0)) goto deny; + taskENTER_CRITICAL(&s_lock); + for (int i = 0; i < 4; ++i) { + challenge_t *entry = &s_challenges[i]; + if (entry->expiry <= now) secure_wipe(entry, sizeof(*entry)); + if (s_ready && epoch == s_epoch && entry->expiry && has_cookie && + equal(entry->token_digest, digest, 32) && equal(entry->origin_digest, origin_digest, 32)) { + if (bootstrap || equal(entry->csrf, csrf, 64)) { + candidate = *entry; + selected = i; + if (login) { secure_wipe(entry, sizeof(*entry)); consumed = true; } + } + } + } + taskEXIT_CRITICAL(&s_lock); + if (bootstrap) { + bool fresh = selected < 0; + if (fresh) { + if (!secret(token) || !secret(candidate.csrf) || + mbedtls_sha256((const uint8_t *)token, 64, candidate.token_digest, 0)) { + status = "503 Service Unavailable"; code = "unavailable"; goto deny; + } + memcpy(candidate.origin_digest, origin_digest, 32); + candidate.expiry = now + CHALLENGE_US; + taskENTER_CRITICAL(&s_lock); + if (s_ready && epoch == s_epoch) for (int i = 0; i < 4; ++i) { + if (!s_challenges[i].expiry) { + s_challenges[i] = candidate; selected = i; challenge_published = true; break; + } + } + taskEXIT_CRITICAL(&s_lock); + if (selected < 0) { result = capacity(r); goto cleanup; } + snprintf(set_cookie, sizeof(set_cookie), PRELOGIN_COOKIE "=%s" COOKIE_FLAGS "120", token); + if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) goto cleanup; + } + snprintf(body, sizeof(body), "{\"csrf\":\"%s\",\"expires_in\":%lld}", candidate.csrf, + (long long)((candidate.expiry - now) / 1000000LL)); + result = response(r, "200 OK", body); + goto cleanup; + } + if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; } + if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup; + size_t received = 0; + int64_t deadline = now + 3000000LL; + while (received < r->content_len && esp_timer_get_time() < deadline) { + int count = httpd_req_recv(r, body + received, r->content_len - received); + if (count <= 0) goto deny; + received += (size_t)count; + } + if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny; + now = esp_timer_get_time(); + unsigned attempts; + int64_t retry; + taskENTER_CRITICAL(&s_lock); + ready = s_ready && epoch == s_epoch; + if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; } + attempts = s_attempts; + if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; } + retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL; + taskEXIT_CRITICAL(&s_lock); + if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; } + if (attempts >= 5) { + char seconds[16]; + snprintf(seconds, sizeof(seconds), "%lld", (long long)retry); + if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup; + result = failure(r, "429 Too Many Requests", "throttled"); + goto cleanup; + } + bool authenticated = false; + user_principal_t principal = {0}; + esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length, + credentials.password, credentials.password_length, &principal, &authenticated); + secure_wipe(body, sizeof(body)); + secure_wipe(&credentials, sizeof(credentials)); + if (error == ESP_OK && authenticated) + error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view); + secure_wipe(&principal, sizeof(principal)); + if (error == ESP_ERR_NO_MEM) { result = capacity(r); goto cleanup; } + if (error != ESP_OK) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; } + if (!authenticated) { status = "401 Unauthorized"; code = "invalid_credentials"; goto deny; } + snprintf(set_cookie, sizeof(set_cookie), SESSION_COOKIE "=%s" COOKIE_FLAGS "3600", token); + if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) { + web_session_store_invalidate(view.id); goto cleanup; + } + result = response(r, "200 OK", "{\"authenticated\":true}"); + if (result != ESP_OK) web_session_store_invalidate(view.id); + goto cleanup; +deny: + result = failure(r, status, code); +cleanup: + if (challenge_published && result != ESP_OK) { + taskENTER_CRITICAL(&s_lock); + if (epoch == s_epoch && selected >= 0 && + equal(s_challenges[selected].token_digest, candidate.token_digest, 32)) + secure_wipe(&s_challenges[selected], sizeof(s_challenges[selected])); + taskEXIT_CRITICAL(&s_lock); + } + web_httpd_wipe_request(r, web_httpd_unread_body(r)); + secure_wipe(&view, sizeof(view)); + secure_wipe(token, sizeof(token)); + secure_wipe(csrf, sizeof(csrf)); + secure_wipe(set_cookie, sizeof(set_cookie)); + secure_wipe(body, sizeof(body)); + secure_wipe(&credentials, sizeof(credentials)); + secure_wipe(&candidate, sizeof(candidate)); + secure_wipe(digest, sizeof(digest)); + return result; +} diff --git a/src/web_cookie_auth.h b/src/web_cookie_auth.h new file mode 100644 index 0000000..e6e0ef2 --- /dev/null +++ b/src/web_cookie_auth.h @@ -0,0 +1,19 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include "esp_http_server.h" +#include "web_session_store.h" + +esp_err_t web_cookie_auth_start(void); +void web_cookie_auth_stop(void); +typedef struct { + uint32_t login_attempts, login_failures, throttled, capacity_rejections; + uint32_t security_rejections, logouts, active_challenges; + bool ready; +} web_cookie_auth_snapshot_t; +void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot); +void web_cookie_auth_clear_counters(void); +/* Sends an error on denial, with allowed=false. View is caller-wiped. */ +esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation, + bool upgrade, web_session_view_t *view, + bool *allowed); +esp_err_t web_cookie_auth_handler(httpd_req_t *request); diff --git a/src/web_httpd_adapter.c b/src/web_httpd_adapter.c new file mode 100644 index 0000000..555df1d --- /dev/null +++ b/src/web_httpd_adapter.c @@ -0,0 +1,106 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Deliberately isolated dependency on the installed IDF HTTPD layout. */ +#include "web_httpd_adapter.h" +#include +#include +#include "esp_idf_version.h" +#include "esp_httpd_priv.h" +#include "secure_random.h" + +#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0) +#error "Reaudit HTTPD header storage and pre-handler upgrade behavior for this IDF" +#endif + +bool web_httpd_headers_valid(httpd_req_t *request) +{ + if (!request || !request->aux) return false; + const struct httpd_req_aux *aux = request->aux; + const char *start = aux->scratch; + if (!start || aux->scratch_cur_size > 1024U) return false; + const char *end = start + aux->scratch_cur_size; + const char *line = start; + for (unsigned i = 0; i < aux->req_hdrs_count; ++i) { + if (line >= end) return false; + while (line < end && !*line) ++line; + const char *stop = memchr(line, 0, (size_t)(end - line)); + if (!stop) return false; + const char *colon = memchr(line, ':', (size_t)(stop - line)); + if (!colon || colon == line) return false; + size_t length = (size_t)(colon - line); + for (const char *p = line; p < colon; ++p) { + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || strchr("!#$%&'*+-.^_`|~", *p))) return false; + } + for (const char *p = colon + 1; p < stop; ++p) { + if ((unsigned char)*p < 32U || (unsigned char)*p == 127U) return false; + } + /* Reject transfer coding and Expect rather than draining an unbounded + * body after an authentication failure. No application route uses them. */ + if ((length == 17U && !strncasecmp(line, "Transfer-Encoding", length)) || + (length == 6U && !strncasecmp(line, "Expect", length))) return false; + const char *previous = start; + for (unsigned j = 0; j < i; ++j) { + while (previous < line && !*previous) ++previous; + const char *previous_end = memchr(previous, 0, (size_t)(line - previous)); + if (!previous_end) return false; + const char *previous_colon = memchr(previous, ':', (size_t)(previous_end - previous)); + if (!previous_colon) return false; + if ((size_t)(previous_colon - previous) == length && + !strncasecmp(previous, line, length)) return false; + previous = previous_end + 1; + } + line = stop + 1; + } + return true; +} + +bool web_httpd_upgrade_requested(httpd_req_t *request) +{ + const struct httpd_req_aux *aux = request->aux; + if (!aux || !aux->sd || !aux->ws_handshake_detect || aux->sd->ws_handshake_done) + return false; + char version[3], key[25]; + if (httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Version") != 2U || + httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Version", version, sizeof(version)) != ESP_OK || + strcmp(version, "13") || httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Key") != 24U || + httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Key", key, sizeof(key)) != ESP_OK || + key[22] != '=' || key[23] != '=' || !strchr("AQgw", key[21])) return false; + for (unsigned i = 0; i < 21; ++i) + if (!strchr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", key[i])) return false; + return true; +} + +esp_err_t web_httpd_upgrade(httpd_req_t *request, + esp_err_t (*handler)(httpd_req_t *)) +{ + if (!web_httpd_upgrade_requested(request)) return ESP_ERR_INVALID_STATE; + esp_err_t error = httpd_ws_respond_server_handshake(request, NULL); + if (error == ESP_OK) { + struct httpd_req_aux *aux = request->aux; + aux->sd->ws_handshake_done = true; + aux->sd->ws_handler = handler; + aux->sd->ws_control_frames = false; + aux->sd->ws_user_ctx = NULL; + } + return error; +} + +void web_httpd_wipe_request(httpd_req_t *request, bool closing) +{ + struct httpd_req_aux *aux = request->aux; + if (!aux) return; + if (aux->scratch) secure_wipe(aux->scratch, aux->scratch_cur_size); + aux->req_hdrs_count = 0; + if (aux->sd) { + size_t keep = closing ? 0 : aux->sd->pending_len; + /* httpd_unrecv()/httpd_recv_pending() right-align unread bytes. */ + if (keep <= sizeof(aux->sd->pending_data)) + secure_wipe(aux->sd->pending_data, sizeof(aux->sd->pending_data) - keep); + } +} + +bool web_httpd_unread_body(httpd_req_t *request) +{ + const struct httpd_req_aux *aux = request->aux; + return aux && aux->remaining_len != 0; +} diff --git a/src/web_httpd_adapter.h b/src/web_httpd_adapter.h new file mode 100644 index 0000000..b45e606 --- /dev/null +++ b/src/web_httpd_adapter.h @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include "esp_http_server.h" + +/* HTTPD-owner only, before body reads or any response. Reject duplicate lines, + * including Cookie, rather than trusting first-match public getters. */ +bool web_httpd_headers_valid(httpd_req_t *request); +bool web_httpd_upgrade_requested(httpd_req_t *request); +bool web_httpd_unread_body(httpd_req_t *request); +/* After the final response/lookup: preserve only unread pipelined data on a + * keepalive connection. Closing requests may discard pending data entirely. */ +void web_httpd_wipe_request(httpd_req_t *request, bool closing); +esp_err_t web_httpd_upgrade(httpd_req_t *request, + esp_err_t (*handler)(httpd_req_t *)); diff --git a/src/web_login_ui.c b/src/web_login_ui.c index 06bdbf5..b138341 100644 --- a/src/web_login_ui.c +++ b/src/web_login_ui.c @@ -110,7 +110,8 @@ static const char s_login_html[] = " const abort = new AbortController(); controller = abort;\n" " const timeout = setTimeout(() => abort.abort(), 15000);\n" " let csrf = '';\n" - " const options = {credentials:'same-origin', mode:'same-origin', cache:'no-store', redirect:'error', signal:abort.signal};\n" + /* CORS mode preserves Origin under no-referrer; CSP still limits connections to self. */ + " const options = {credentials:'same-origin', mode:'cors', cache:'no-store', redirect:'error', signal:abort.signal};\n" " try {\n" " const challenge = await fetch('/api/login-challenge', {...options, headers:{'X-Login-Bootstrap':'1'}});\n" " if (current !== generation) return;\n" @@ -152,7 +153,7 @@ esp_err_t web_login_ui_send_response(httpd_req_t *request) if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Frame-Options", "DENY"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Content-Security-Policy", - "default-src 'none'; script-src 'sha256-x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo='; " + "default-src 'none'; script-src 'sha256-eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ='; " "style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; " "form-action 'none'; frame-ancestors 'none'"); if (error == ESP_OK) error = httpd_resp_send(request, s_login_html, sizeof(s_login_html) - 1U); diff --git a/src/web_login_ui.h b/src/web_login_ui.h index e368b08..062c2ce 100644 --- a/src/web_login_ui.h +++ b/src/web_login_ui.h @@ -4,7 +4,6 @@ #include "esp_err.h" #include "esp_http_server.h" -/* Standalone public login document for the future atomic 8D.3 cutover. - * Rendering only: no authentication, URI registration, or session allocation. - * Do not expose this page until its protected API and application routes exist. */ +/* Standalone public login document. Rendering only: authentication, route + * registration and bounded challenge allocation belong to web_cookie_auth. */ esp_err_t web_login_ui_send_response(httpd_req_t *request); diff --git a/src/web_serial_transport.c b/src/web_serial_transport.c index 954b1bf..ebe6989 100644 --- a/src/web_serial_transport.c +++ b/src/web_serial_transport.c @@ -14,6 +14,8 @@ #include "sdkconfig.h" #include "secure_random.h" #include "serial_service.h" +#include "web_auth_parse.h" +#include "web_httpd_adapter.h" #if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT #error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT" @@ -104,8 +106,7 @@ static uint64_t s_ticket_epoch; static esp_err_t identity_is_current(const user_principal_t *principal, web_session_id_t id, bool *current) { - return id == 0U ? user_database_principal_is_current(principal, current) - : web_session_store_check_principal(id, principal, current); + return web_session_store_check_principal(id, principal, current); } static TickType_t milliseconds_to_ticks(uint32_t milliseconds) @@ -270,9 +271,6 @@ static esp_err_t validate_origin(httpd_req_t *request) esp_err_t result = httpd_req_get_hdr_value_str( request, "Origin", origin, sizeof(origin)); - if (result == ESP_ERR_NOT_FOUND) { - return ESP_OK; - } if (result != ESP_OK) { return ESP_ERR_INVALID_ARG; } @@ -284,9 +282,7 @@ static esp_err_t validate_origin(httpd_req_t *request) return ESP_ERR_INVALID_ARG; } - int written = snprintf(expected, sizeof(expected), "https://%s", host); - bool matches = written > 0 && (size_t)written < sizeof(expected) && - strcmp(origin, expected) == 0; + bool matches = web_auth_parse_origin(host, strlen(host), origin, strlen(origin), expected); secure_wipe(origin, sizeof(origin)); secure_wipe(host, sizeof(host)); secure_wipe(expected, sizeof(expected)); @@ -652,6 +648,12 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd, } bool activated = false; + /* Ticket and principal admission must succeed before HTTP 101. */ + result = web_httpd_upgrade(request, web_serial_transport_ws_handler); + if (result != ESP_OK) { + close_unpublished_broker_session(slot, slot_generation, client_id); + goto cleanup; + } int64_t next_currentness_check_us = monotonic_time_us() + WEB_SERIAL_CURRENTNESS_INTERVAL_US; taskENTER_CRITICAL(&s_lock); @@ -1576,6 +1578,26 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal, return ESP_ERR_INVALID_STATE; } + /* Reclaim stale identities without database calls under the transport lock. + * A late result must not clear a ticket published into the same array slot. */ + for (size_t i = 0; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) { + taskENTER_CRITICAL(&s_lock); + web_serial_ticket_t candidate = s_tickets[i]; + taskEXIT_CRITICAL(&s_lock); + bool live = false; + if (candidate.active && + (identity_is_current(&candidate.principal, candidate.web_session_id, &live) != ESP_OK || !live)) { + taskENTER_CRITICAL(&s_lock); + web_serial_ticket_t *entry = &s_tickets[i]; + if (entry->active && entry->web_session_id == candidate.web_session_id && + entry->expires_at_us == candidate.expires_at_us && + constant_time_equal(entry->digest, candidate.digest, sizeof(entry->digest))) + clear_ticket_locked(entry); + taskEXIT_CRITICAL(&s_lock); + } + secure_wipe(&candidate, sizeof(candidate)); + } + uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0}; uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0}; result = secure_random_fill(random_bytes, sizeof(random_bytes)); @@ -1602,7 +1624,6 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal, epoch != UINT64_MAX) { purge_tickets_locked(now_us); size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS; - int64_t oldest_expiry = INT64_MAX; for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) { web_serial_ticket_t *entry = &s_tickets[index]; @@ -1610,10 +1631,6 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal, selected = index; break; } - if (entry->expires_at_us < oldest_expiry) { - oldest_expiry = entry->expires_at_us; - selected = index; - } } if (selected < WEB_SERIAL_TRANSPORT_MAX_TICKETS) { web_serial_ticket_t *entry = &s_tickets[selected]; @@ -1688,6 +1705,9 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request( if (result == ESP_OK) { result = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); } + if (result == ESP_OK) { + result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); + } if (result == ESP_OK) { result = httpd_resp_send(request, response, written); } @@ -1715,12 +1735,14 @@ esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request, httpd_ws_client_info_t info = httpd_ws_get_fd_info(request->handle, socket_fd); - if (info == HTTPD_WS_CLIENT_HTTP) { + bool opening = request->sess_ctx == NULL && web_session_id != 0U && + request->method == HTTP_GET && web_httpd_upgrade_requested(request); + if (info == HTTPD_WS_CLIENT_HTTP && !opening) { (void)send_plain_bad_request(request); add_counter(&s_counters.protocol_errors, 1U); return ESP_FAIL; } - if (info != HTTPD_WS_CLIENT_WEBSOCKET) { + if (info != HTTPD_WS_CLIENT_WEBSOCKET && !opening) { return ESP_FAIL; } @@ -1736,7 +1758,7 @@ esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request, esp_err_t result; if (request->sess_ctx == NULL) { - /* IDF has already sent 101; authentication failures must only close. */ + /* The registered HTTP route defers 101 until admission. */ result = connect_websocket(request, socket_fd, web_session_id); } else { result = process_websocket_frame(request); diff --git a/src/web_serial_transport.h b/src/web_serial_transport.h index 717bd40..90d8d96 100644 --- a/src/web_serial_transport.h +++ b/src/web_serial_transport.h @@ -100,7 +100,7 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server); /* * Mint a one-time bearer ticket bound to a current authenticated principal and - * originating web-session ID (zero only for the shipped Basic path). The + * nonzero originating web-session ID. The * principal is copied; the output is exactly 32 Base64URL characters plus a * terminator and expires after 30 monotonic seconds. Never log or persist it. */ @@ -111,7 +111,7 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal, /* * Convenience POST response helper for /api/ws-ticket. Authentication is * intentionally outside this module: pass the principal returned by successful - * authentication, and its session ID (zero for Basic). Cookie callers must also + * authentication, and its session ID. Callers must also * enforce CSRF/Origin policy. Register as HTTP_POST, not as a public handler. */ esp_err_t web_serial_transport_handle_authenticated_ticket_request( @@ -119,13 +119,13 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request( web_session_id_t web_session_id); /* - * Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and - * handle_ws_control_frames=false. The initial upgraded GET authenticates the - * ticket; later invocations process one complete data frame. + * Frame callback installed by the HTTPD adapter after authorized admission. + * Do not register directly: the initial HTTP GET must pass cookie/Origin policy + * and call the session handler below before any 101 response. */ esp_err_t web_serial_transport_ws_handler(httpd_req_t *request); -/* Trusted future cookie-authorized upgrade caller; must validate cookie/Origin - * first. Zero identifies only the shipped Basic path, never a cookie fallback. */ +/* Trusted cookie-authorized upgrade caller; validate cookie/Origin first. + * Zero is invalid for initial admission; no Basic fallback exists. */ esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request, web_session_id_t web_session_id); /* Invalidates the store first, then marks only matching tickets/slots for owner @@ -140,7 +140,7 @@ esp_err_t web_serial_transport_clear_counters(void); /* Invalidate cookie records and tickets/sockets for one username (also after * deletion), or all accounts. Store invalidation occurs even if serial init - * failed; these do not touch the HTTPD-owned Basic cache, which rechecks DB. */ + * failed. Authoritative store/principal checks supplement notifications. */ esp_err_t web_serial_transport_revoke_user(const uint8_t *username, size_t username_length); esp_err_t web_serial_transport_revoke_sessions(void); diff --git a/src/web_server.c b/src/web_server.c index 6c6039f..8786526 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -1,5 +1,5 @@ /* SPDX-License-Identifier: GPL-3.0-only */ -/* TLS-only HTTP server with bounded Basic authentication and status output. */ +/* TLS-only HTTP server with bounded cookie authentication and status output. */ #include "web_server.h" @@ -15,8 +15,6 @@ #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" -#include "mbedtls/base64.h" -#include "mbedtls/md.h" #include "secure_random.h" #include "serial_config.h" #include "serial_service.h" @@ -26,28 +24,13 @@ #include "web_security.h" #include "web_serial_transport.h" #include "web_session_store.h" +#include "web_cookie_auth.h" +#include "web_httpd_adapter.h" #include "web_ui.h" #include "wifi_manager.h" #define WEB_SERVER_PORT 443U -#define WEB_SERVER_MAX_BASIC_DECODED \ - (USER_DATABASE_USERNAME_CAPACITY + 1U + USER_DATABASE_PASSWORD_CAPACITY) -#define WEB_SERVER_MAX_BASIC_ENCODED \ - (((WEB_SERVER_MAX_BASIC_DECODED + 2U) / 3U) * 4U) -#define WEB_SERVER_MAX_AUTHORIZATION \ - ((sizeof("Basic ") - 1U) + WEB_SERVER_MAX_BASIC_ENCODED + 1U) #define WEB_SERVER_STATUS_JSON_CAPACITY 3072U -#define WEB_SERVER_AUTH_CACHE_ENTRIES 4U -#define WEB_SERVER_AUTH_CACHE_KEY_LENGTH 32U -#define WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH 32U -#define WEB_SERVER_AUTH_CACHE_TTL_US 300000000LL - -typedef struct { - bool active; - int64_t expires_at_us; - uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH]; - user_principal_t principal; -} web_server_auth_cache_entry_t; static SemaphoreHandle_t s_server_mutex; static httpd_handle_t s_server; @@ -59,10 +42,6 @@ static bool s_serial_transport_attached; static esp_err_t s_last_error = ESP_ERR_INVALID_STATE; static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE; static web_server_counters_t s_counters; -static bool s_auth_cache_ready; -static uint8_t s_auth_cache_key[WEB_SERVER_AUTH_CACHE_KEY_LENGTH]; -static web_server_auth_cache_entry_t - s_auth_cache[WEB_SERVER_AUTH_CACHE_ENTRIES]; static esp_err_t ensure_mutex(void) { @@ -112,188 +91,19 @@ static esp_err_t send_plain_error(httpd_req_t *request, return error; } -static esp_err_t send_authentication_required(httpd_req_t *request) -{ - esp_err_t error = httpd_resp_set_hdr( - request, "WWW-Authenticate", - "Basic realm=\"ESP32-SAK\", charset=\"UTF-8\""); - if (error != ESP_OK) { - increment_counter(&s_counters.response_errors); - return error; - } - return send_plain_error(request, "401 Unauthorized", "Authentication required.\n"); -} - -static bool constant_time_equal(const uint8_t *left, const uint8_t *right, - size_t length) -{ - uint8_t difference = 0U; - for (size_t index = 0U; index < length; ++index) { - difference |= left[index] ^ right[index]; - } - return difference == 0U; -} - -static esp_err_t calculate_auth_cache_digest( - const char *authorization, size_t authorization_length, - uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH]) -{ - if (!s_auth_cache_ready) { - return ESP_ERR_INVALID_STATE; - } - const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); - if (info == NULL || - mbedtls_md_hmac(info, s_auth_cache_key, sizeof(s_auth_cache_key), - (const uint8_t *)authorization, authorization_length, - digest) != 0) { - return ESP_FAIL; - } - return ESP_OK; -} - -static bool authenticate_from_cache( - const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH], - user_principal_t *principal) -{ - int64_t now = esp_timer_get_time(); - for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) { - web_server_auth_cache_entry_t *entry = &s_auth_cache[index]; - if (!entry->active || entry->expires_at_us <= now || - !constant_time_equal(entry->digest, digest, sizeof(entry->digest))) { - if (entry->active && entry->expires_at_us <= now) { - secure_wipe(entry, sizeof(*entry)); - } - continue; - } - - bool current = false; - if (user_database_principal_is_current(&entry->principal, ¤t) == ESP_OK && - current) { - *principal = entry->principal; - entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US; - return true; - } - secure_wipe(entry, sizeof(*entry)); - return false; - } - return false; -} - -static void store_authenticated_request( - const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH], - const user_principal_t *principal) -{ - int64_t now = esp_timer_get_time(); - size_t selected = 0U; - int64_t earliest_expiry = INT64_MAX; - for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) { - web_server_auth_cache_entry_t *entry = &s_auth_cache[index]; - if (entry->active && - constant_time_equal(entry->digest, digest, sizeof(entry->digest))) { - selected = index; - break; - } - if (!entry->active || entry->expires_at_us <= now) { - selected = index; - earliest_expiry = INT64_MIN; - } else if (earliest_expiry != INT64_MIN && - entry->expires_at_us < earliest_expiry) { - selected = index; - earliest_expiry = entry->expires_at_us; - } - } - - web_server_auth_cache_entry_t *entry = &s_auth_cache[selected]; - secure_wipe(entry, sizeof(*entry)); - entry->active = true; - entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US; - memcpy(entry->digest, digest, sizeof(entry->digest)); - entry->principal = *principal; -} - -static esp_err_t authenticate_request(httpd_req_t *request, - user_principal_t *principal, - bool *authenticated) -{ - char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0}; - uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0}; - size_t decoded_length = 0U; - uint8_t cache_digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH] = {0}; - bool cache_digest_valid = false; - esp_err_t result = ESP_OK; - memset(principal, 0, sizeof(*principal)); - *authenticated = false; - - increment_counter(&s_counters.requests); - size_t header_length = httpd_req_get_hdr_value_len(request, "Authorization"); - if (header_length == 0U || header_length >= sizeof(authorization)) { - goto cleanup; - } - if (httpd_req_get_hdr_value_str(request, "Authorization", - authorization, sizeof(authorization)) != ESP_OK || - header_length < 7U || strncasecmp(authorization, "Basic ", 6U) != 0) { - goto cleanup; - } - - result = calculate_auth_cache_digest(authorization, header_length, cache_digest); - if (result != ESP_OK) { - goto cleanup; - } - cache_digest_valid = true; - if (authenticate_from_cache(cache_digest, principal)) { - *authenticated = true; - goto cleanup; - } - - int decode_result = mbedtls_base64_decode( - decoded, sizeof(decoded), &decoded_length, - (const unsigned char *)authorization + 6U, header_length - 6U); - if (decode_result != 0 || decoded_length == 0U) { - goto cleanup; - } - - uint8_t *separator = memchr(decoded, ':', decoded_length); - if (separator == NULL) { - goto cleanup; - } - size_t username_length = (size_t)(separator - decoded); - size_t password_length = decoded_length - username_length - 1U; - result = user_database_authenticate_password( - decoded, username_length, separator + 1U, password_length, - principal, authenticated); - if (result == ESP_OK && *authenticated && cache_digest_valid) { - store_authenticated_request(cache_digest, principal); - } - -cleanup: - secure_wipe(authorization, sizeof(authorization)); - secure_wipe(decoded, sizeof(decoded)); - secure_wipe(cache_digest, sizeof(cache_digest)); - if (result != ESP_OK) { - memset(principal, 0, sizeof(*principal)); - return result; - } - if (*authenticated) { - increment_counter(&s_counters.authenticated_requests); - } else { - memset(principal, 0, sizeof(*principal)); - increment_counter(&s_counters.authentication_failures); - } - return ESP_OK; -} - static esp_err_t authorize_or_respond(httpd_req_t *request, user_principal_t *principal, bool *authorized) { - *authorized = false; - esp_err_t error = authenticate_request(request, principal, authorized); - if (error != ESP_OK) { - *authorized = false; - return send_plain_error(request, "503 Service Unavailable", - "Authentication service unavailable.\n"); - } - return *authorized ? ESP_OK : send_authentication_required(request); + web_session_view_t view = {0}; + increment_counter(&s_counters.requests); + esp_err_t error = web_cookie_auth_require(request, false, false, &view, authorized); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + *principal = view.principal; + secure_wipe(&view, sizeof(view)); + increment_counter(*authorized ? &s_counters.authenticated_requests : + &s_counters.authentication_failures); + return error; } static esp_err_t send_authenticated_ui(httpd_req_t *request, @@ -331,23 +141,21 @@ static esp_err_t asset_handler(httpd_req_t *request) static esp_err_t ticket_handler(httpd_req_t *request) { - user_principal_t principal = {0}; + web_session_view_t view = {0}; bool authorized = false; - esp_err_t error = authorize_or_respond(request, &principal, &authorized); + increment_counter(&s_counters.requests); + esp_err_t error = web_cookie_auth_require(request, true, false, &view, &authorized); + increment_counter(authorized ? &s_counters.authenticated_requests : &s_counters.authentication_failures); if (error != ESP_OK || !authorized) { - secure_wipe(&principal, sizeof(principal)); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; } increment_counter(&s_counters.ticket_requests); - if (request->content_len != 0U) { - secure_wipe(&principal, sizeof(principal)); - return send_plain_error(request, "400 Bad Request", - "Ticket requests must have an empty body.\n"); - } - error = web_serial_transport_handle_authenticated_ticket_request( - request, &principal, 0U); - secure_wipe(&principal, sizeof(principal)); + request, &view.principal, view.id); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); if (error == ESP_OK) { return ESP_OK; } @@ -355,9 +163,15 @@ static esp_err_t ticket_handler(httpd_req_t *request) return send_plain_error(request, "400 Bad Request", "Invalid web-terminal ticket request.\n"); } + if (error == ESP_ERR_NO_MEM && + httpd_resp_set_hdr(request, "Retry-After", "5") == ESP_OK) { + return send_plain_error(request, "503 Service Unavailable", "{\"error\":\"capacity\"}"); + } increment_counter(&s_counters.response_errors); - return send_plain_error(request, "503 Service Unavailable", - "Web terminal transport unavailable.\n"); + if (error == ESP_ERR_INVALID_STATE) + return send_plain_error(request, "503 Service Unavailable", + "Web terminal transport unavailable.\n"); + return error; /* A failed/partial send must close, not send a second response. */ } static const char *safe_string(const char *value) @@ -533,12 +347,24 @@ static const httpd_uri_t s_ticket_uri = { .user_ctx = NULL, }; +static esp_err_t websocket_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + bool allowed = false; + esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed); + if (allowed) error = web_serial_transport_session_ws_handler(request, view.id); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} + static const httpd_uri_t s_websocket_uri = { .uri = WEB_SERIAL_TRANSPORT_WS_URI, .method = HTTP_GET, - .handler = web_serial_transport_ws_handler, + .handler = websocket_handler, .user_ctx = NULL, - .is_websocket = true, + /* Authorize and admit before the adapter sends 101, not IDF's pre-handler path. */ + .is_websocket = false, .handle_ws_control_frames = false, }; @@ -589,6 +415,22 @@ static const httpd_uri_t *const s_uri_handlers[] = { &s_logo_uri, }; +static const httpd_uri_t s_auth_uris[] = { + {.uri = "/login", .method = HTTP_GET, .handler = web_cookie_auth_handler}, + {.uri = "/api/login-challenge", .method = HTTP_GET, .handler = web_cookie_auth_handler}, + {.uri = "/api/login", .method = HTTP_POST, .handler = web_cookie_auth_handler}, + {.uri = "/api/session", .method = HTTP_GET, .handler = web_cookie_auth_handler}, + {.uri = "/api/logout", .method = HTTP_POST, .handler = web_cookie_auth_handler}, +}; + +static esp_err_t route_error_handler(httpd_req_t *request, httpd_err_code_t code) +{ + (void)send_plain_error(request, + code == HTTPD_405_METHOD_NOT_ALLOWED ? "405 Method Not Allowed" : "404 Not Found", + "Unsupported route or method.\n"); + return ESP_FAIL; /* Do not drain a rejected request body on keepalive. */ +} + esp_err_t web_server_init(void) { esp_err_t error = ensure_mutex(); @@ -596,19 +438,6 @@ esp_err_t web_server_init(void) return error; } - xSemaphoreTake(s_server_mutex, portMAX_DELAY); - if (!s_auth_cache_ready) { - error = secure_random_fill(s_auth_cache_key, sizeof(s_auth_cache_key)); - if (error == ESP_OK) { - secure_wipe(s_auth_cache, sizeof(s_auth_cache)); - s_auth_cache_ready = true; - } - } - xSemaphoreGive(s_server_mutex); - if (error != ESP_OK) { - return error; - } - bool initialize_serial_transport = false; xSemaphoreTake(s_server_mutex, portMAX_DELAY); if (!s_serial_transport_init_attempted) { @@ -654,12 +483,8 @@ esp_err_t web_server_start(void) serial_transport_ready = s_serial_transport_initialized; xSemaphoreGive(s_server_mutex); - /* Initialize only after admission: a rejected start must not undo stop. - * Dormant Phase 8D primitives do not gate the existing Basic-auth service. */ - esp_err_t session_error = web_session_store_init(); - if (session_error != ESP_OK) { - ESP_LOGW("web_server", "Session store unavailable: %s", esp_err_to_name(session_error)); - } + /* Initialize only after lifecycle admission; failure gates all HTTPS auth. */ + error = web_cookie_auth_start(); uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0}; uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0}; @@ -667,7 +492,7 @@ esp_err_t web_server_start(void) size_t private_key_length = 0U; httpd_handle_t server = NULL; - error = web_security_copy_tls_material( + if (error == ESP_OK) error = web_security_copy_tls_material( certificate, sizeof(certificate), &certificate_length, private_key, sizeof(private_key), &private_key_length); if (error == ESP_OK) { @@ -675,7 +500,8 @@ esp_err_t web_server_start(void) /* Two browser terminals retain room for parallel assets and status fetches. */ config.httpd.max_open_sockets = 6; config.httpd.max_uri_handlers = - sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]); + sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); config.httpd.lru_purge_enable = true; config.httpd.recv_wait_timeout = 1; config.httpd.send_wait_timeout = 1; @@ -698,13 +524,19 @@ esp_err_t web_server_start(void) } bool serial_transport_attached = false; + for (size_t i = 0; error == ESP_OK && i < sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); ++i) + error = httpd_register_uri_handler(server, &s_auth_uris[i]); + if (error == ESP_OK) + error = httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, route_error_handler); + if (error == ESP_OK) + error = httpd_register_err_handler(server, HTTPD_405_METHOD_NOT_ALLOWED, route_error_handler); esp_err_t attach_error = s_serial_transport_error; if (error == ESP_OK && serial_transport_ready) { attach_error = web_serial_transport_attach_server(server); serial_transport_attached = attach_error == ESP_OK; } if (error != ESP_OK) { - web_session_store_stop(); + web_cookie_auth_stop(); } if (error != ESP_OK && server != NULL) { esp_err_t cleanup_error = httpd_ssl_stop(server); @@ -750,7 +582,7 @@ esp_err_t web_server_stop(void) s_transitioning = true; xSemaphoreGive(s_server_mutex); - web_session_store_stop(); + web_cookie_auth_stop(); if (serial_transport_attached) { esp_err_t detach_error = web_serial_transport_detach_server(server); if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) { @@ -813,5 +645,6 @@ esp_err_t web_server_clear_counters(void) xSemaphoreTake(s_server_mutex, portMAX_DELAY); memset(&s_counters, 0, sizeof(s_counters)); xSemaphoreGive(s_server_mutex); + web_cookie_auth_clear_counters(); return ESP_OK; } diff --git a/src/web_ui.c b/src/web_ui.c index c2f75dc..4c86d1d 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -9,7 +9,7 @@ #include "web_assets_data.h" -#define WEB_UI_DOCUMENT_CACHE_CONTROL "private, max-age=300" +#define WEB_UI_DOCUMENT_CACHE_CONTROL "no-store" #define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800" static const char s_index_html[] = @@ -87,6 +87,34 @@ static const char s_index_html[] = ".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(0,1fr)}}\n" "\n" "\n" + "\n" "\n" "\n" "\n" @@ -120,11 +148,12 @@ static const char s_index_html[] = "\n" "\n" "\n" + "\n" "\n" + "

Session lasts one hour from sign-in (absolute expiry).

\n" "

" "Observer mode — terminal input is disabled.

\n" - "

" - "Requesting a one-time connection ticket…

\n" + "

Loading application… If loading fails, open login or reload this page.

\n" "\n" "\n" "
\n" @@ -152,6 +181,8 @@ static const char s_app_js[] = "const requestControl = element('request-control');\n" "const releaseControl = element('release-control');\n" "const connectionToggle = element('connection-toggle');\n" + "const signOut = element('sign-out');\n" + "const sessionInfo = element('session-info');\n" "const terminalHost = element('terminal');\n" "const terminal = new Terminal({\n" " allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n" @@ -182,6 +213,138 @@ static const char s_app_js[] = "let lastFitHeight = 0;\n" "let statusInFlight = false;\n" "let statusTimer = null;\n" + "let csrf = '';\n" + "let sessionDeadline = 0;\n" + "let expiryTimer = null;\n" + "let navigating = false;\n" + "let loggingOut = false;\n" + "let suspended = false;\n" + "let workGeneration = 0;\n" + "let sessionGeneration = 0;\n" + "const requests = new Set();\n" + "const live = (generation) => generation === workGeneration && !unloading && !navigating;\n" + "const cancelWork = () => {\n" + " ++workGeneration;\n" + " ++connectionGeneration;\n" + " clearReconnectTimer();\n" + " for (const controller of requests) controller.abort();\n" + " requests.clear();\n" + " if (ticketAbort) ticketAbort.abort();\n" + " ticketAbort = null;\n" + " if (statusTimer !== null) window.clearInterval(statusTimer);\n" + " statusTimer = null;\n" + " statusInFlight = false;\n" + " window.clearTimeout(expiryTimer);\n" + " expiryTimer = null;\n" + " if (socket) { const previous = socket; socket = null; previous.close(); }\n" + " clientId = null;\n" + " clientIdField.textContent = '—';\n" + " setRole('observer');\n" + "};\n" + "const login = () => {\n" + " if (navigating) return;\n" + " navigating = true;\n" + " csrf = '';\n" + " cancelWork();\n" + " reconnectEnabled = false;\n" + " if (fitFrame) window.cancelAnimationFrame(fitFrame);\n" + " updateControls();\n" + " if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace('/login'); }\n" + "};\n" + "window.sakSessionExpired = login;\n" + "if (window.sakLoginNavigating) { navigating = true; reconnectEnabled = false; }\n" + "// Keep authentication bodies bounded even if the peer sends chunked data.\n" + "async function readJson(response, limit) {\n" + " const reader = response.body.getReader();\n" + " const bytes = new Uint8Array(limit);\n" + " let length = 0;\n" + " try {\n" + " for (;;) {\n" + " const {done, value} = await reader.read();\n" + " if (done) break;\n" + " if (length + value.length > limit) throw new Error('Invalid device response.');\n" + " bytes.set(value, length); length += value.length;\n" + " }\n" + " return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0, length)));\n" + " } finally { await reader.cancel().catch(() => {}); }\n" + "}\n" + "async function api(path, generation, {method = 'GET', signal, limit = 512, current = () => true} = {}) {\n" + " const controller = new AbortController();\n" + " const abort = () => controller.abort();\n" + " if (signal) { signal.addEventListener('abort', abort, {once: true}); if (signal.aborted) abort(); }\n" + " requests.add(controller);\n" + " const timeout = window.setTimeout(abort, 15000);\n" + " try {\n" + /* Non-CORS POST with no-referrer serializes Origin as null in browsers. */ + " const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n" + " cache: 'no-store', redirect: 'error', signal: controller.signal,\n" + " ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf}, body: ''} : {})});\n" + " if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n" + " if (response.status === 401) { login(); throw new Error('Session ended.'); }\n" + " if (!response.ok) {\n" + " const error = new Error(response.status === 403 ? 'Session security check failed. Reload the session and retry explicitly.' :\n" + " response.status === 429 || response.status === 503 ? 'Device capacity or backoff limit. Try again later.' : 'Device request failed.');\n" + " error.status = response.status;\n" + " const retry = response.headers.get('Retry-After');\n" + " error.retry = /^[0-9]{1,4}$/.test(retry || '') ? Math.min(3600, Math.max(1, Number(retry))) : 5;\n" + " if (response.status === 429 || response.status === 503) error.message += ` Wait ${error.retry} second(s).`;\n" + " throw error;\n" + " }\n" + " const payload = response.status === 204 ? null : await readJson(response, limit);\n" + " if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n" + " return {status: response.status, payload};\n" + " } finally {\n" + " controller.abort(); requests.delete(controller); window.clearTimeout(timeout);\n" + " if (signal) signal.removeEventListener('abort', abort);\n" + " }\n" + "}\n" + "async function loadSession(generation, signal) {\n" + " const sequence = ++sessionGeneration;\n" + " const current = () => sequence === sessionGeneration;\n" + " const {payload} = await api('/api/session', generation, {signal, current});\n" + " if (!live(generation) || !current() || (signal && signal.aborted)) return false;\n" + " if (!payload || typeof payload.username !== 'string' || !payload.username.length || encoder.encode(payload.username).length > 16 ||\n" + " !['user', 'admin'].includes(payload.role) || !/^[0-9a-f]{64}$/.test(payload.csrf) ||\n" + " !Number.isInteger(payload.expires_in) || payload.expires_in < 0 || payload.expires_in > 3600) {\n" + " throw new Error('Invalid session response. Reload to retry.');\n" + " }\n" + " csrf = payload.csrf; payload.csrf = '';\n" + " const deadline = Date.now() + payload.expires_in * 1000;\n" + " sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n" + " sessionInfo.textContent = `${payload.username} · Session expires at ${new Date(sessionDeadline).toLocaleTimeString()} (one hour absolute; traffic does not extend it).`;\n" + " window.clearTimeout(expiryTimer);\n" + " if (!loggingOut) expiryTimer = window.setTimeout(() => { if (live(generation)) login(); }, Math.max(0, sessionDeadline - Date.now()));\n" + " return true;\n" + "}\n" + "function startPolling() {\n" + " if (statusTimer === null) { pollStatus(); statusTimer = window.setInterval(pollStatus, 5000); }\n" + "}\n" + "async function logout() {\n" + " if (loggingOut || unloading || navigating) return;\n" + " loggingOut = true; suspended = true; reconnectEnabled = false;\n" + " cancelWork(); updateControls();\n" + " const generation = workGeneration;\n" + " setConnection('Signing out', 'warn', 'Serial disconnected. Waiting for logout confirmation…');\n" + " try {\n" + " if (!await loadSession(generation)) return;\n" + " const result = await api('/api/logout', generation, {method: 'POST'});\n" + " if (live(generation) && result.status === 204) { login(); return; }\n" + " throw new Error('Logout was not confirmed.');\n" + " } catch (error) {\n" + " if (!live(generation)) return;\n" + " // Only a lost/network response needs confirmation; never repeat the mutation.\n" + " if (!error.status) {\n" + " try { await loadSession(generation); } catch (_) {}\n" + " }\n" + " if (!live(generation)) return;\n" + " csrf = '';\n" + " setConnection('Sign out not confirmed', 'bad', error.status ? error.message :\n" + " 'Network failure: sign out is not confirmed. Retry Sign out, or select Connect to check the session and resume.');\n" + " } finally {\n" + " if (live(generation)) { loggingOut = false; updateControls(); }\n" + " }\n" + "}\n" + "signOut.addEventListener('click', logout);\n" "const setBadge = (target, text, tone) => {\n" " target.textContent = text;\n" " target.dataset.tone = tone;\n" @@ -195,7 +358,8 @@ static const char s_app_js[] = " requestControl.disabled = !socketOpen() || writer;\n" " releaseControl.disabled = !socketOpen() || !writer;\n" " const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n" - " connectionToggle.disabled = unloading;\n" + " connectionToggle.disabled = unloading || navigating || loggingOut;\n" + " signOut.disabled = unloading || navigating || loggingOut;\n" " connectionToggle.textContent = connectionActive ? 'Disconnect' : 'Connect';\n" " connectionToggle.classList.toggle('danger', connectionActive);\n" " inputState.dataset.enabled = writer ? 'true' : 'false';\n" @@ -220,7 +384,7 @@ static const char s_app_js[] = " }\n" "};\n" "const scheduleReconnect = () => {\n" - " if (unloading || !reconnectEnabled || reconnectTimer !== null) return;\n" + " if (unloading || navigating || suspended || !reconnectEnabled || reconnectTimer !== null) return;\n" " const delay = reconnectDelay;\n" " reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n" " setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n" @@ -257,24 +421,18 @@ static const char s_app_js[] = " terminal.write(new Uint8Array(event.data));\n" " }\n" "};\n" - "async function requestTicket(signal) {\n" - " const response = await fetch('/api/ws-ticket', {\n" - " method: 'POST', credentials: 'same-origin', cache: 'no-store', signal\n" - " });\n" - " if (!response.ok) throw new Error('ticket request failed');\n" - " const payload = await response.json();\n" - " if (payload === null || typeof payload !== 'object' ||\n" - " typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n" - " throw new Error('invalid ticket response');\n" + "async function requestTicket(signal, generation) {\n" + " const {payload} = await api('/api/ws-ticket', generation, {method: 'POST', signal});\n" + " if (!payload || typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n" + " throw new Error('Invalid ticket response.');\n" " }\n" - " const ticket = payload.ticket;\n" - " payload.ticket = '';\n" - " return ticket;\n" + " const ticket = payload.ticket; payload.ticket = ''; return ticket;\n" "}\n" "async function connect() {\n" - " if (unloading || !reconnectEnabled) return;\n" + " if (unloading || navigating || loggingOut || suspended || !reconnectEnabled) return;\n" " clearReconnectTimer();\n" " const generation = ++connectionGeneration;\n" + " const work = workGeneration;\n" " if (ticketAbort !== null) ticketAbort.abort();\n" " ticketAbort = new AbortController();\n" " if (socket !== null) {\n" @@ -287,7 +445,10 @@ static const char s_app_js[] = " setRole('observer');\n" " setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n" " try {\n" - " const ticket = await requestTicket(ticketAbort.signal);\n" + " const signal = ticketAbort.signal;\n" + " if (!await loadSession(work, signal) || generation !== connectionGeneration) return;\n" + " startPolling();\n" + " const ticket = await requestTicket(signal, work);\n" " if (unloading || generation !== connectionGeneration) return;\n" " ticketAbort = null;\n" " const url = new URL('/ws/serial', window.location.origin);\n" @@ -298,19 +459,19 @@ static const char s_app_js[] = " nextSocket.binaryType = 'arraybuffer';\n" " socket = nextSocket;\n" " nextSocket.addEventListener('open', () => {\n" - " if (socket !== nextSocket) return;\n" + " if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n" " setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n" " });\n" " nextSocket.addEventListener('message', (event) => {\n" - " if (socket === nextSocket) handleSocketMessage(event);\n" + " if (live(work) && generation === connectionGeneration && socket === nextSocket) handleSocketMessage(event);\n" " });\n" " nextSocket.addEventListener('error', () => {\n" - " if (socket === nextSocket) {\n" + " if (live(work) && generation === connectionGeneration && socket === nextSocket) {\n" " setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n" " }\n" " });\n" " nextSocket.addEventListener('close', () => {\n" - " if (socket !== nextSocket) return;\n" + " if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n" " socket = null;\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" @@ -320,7 +481,15 @@ static const char s_app_js[] = " } catch (error) {\n" " if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n" " ticketAbort = null;\n" + " if (!live(work)) return;\n" + " if (error.status === 403 || (error.status && error.status !== 429 && error.status !== 503)) {\n" + " reconnectEnabled = false;\n" + " setConnection('Request failed', 'bad', error.message + ' Select Connect to reload the session.');\n" + " return;\n" + " }\n" + " if (error.status === 429 || error.status === 503) reconnectDelay = error.retry * 1000;\n" " scheduleReconnect();\n" + " if (error.status) connectionDetail.textContent = error.message + ` Retrying in ${error.retry} second(s).`;\n" " }\n" "}\n" "terminal.onData((data) => {\n" @@ -337,6 +506,8 @@ static const char s_app_js[] = " if (role === 'writer' && socketOpen()) socket.send('release-writer');\n" "});\n" "connectionToggle.addEventListener('click', () => {\n" + " if (unloading || navigating || loggingOut) return;\n" + " suspended = false;\n" " const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n" " if (!connectionActive) {\n" " reconnectEnabled = true;\n" @@ -371,7 +542,7 @@ static const char s_app_js[] = " } catch (_) {}\n" "};\n" "const scheduleFit = () => {\n" - " if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n" + " if (!unloading && !navigating && fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n" "};\n" "const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n" "if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n" @@ -415,38 +586,51 @@ static const char s_app_js[] = " }\n" "};\n" "async function pollStatus() {\n" - " if (unloading || statusInFlight) return;\n" + " if (unloading || navigating || suspended || !csrf || statusInFlight) return;\n" + " const generation = workGeneration;\n" " statusInFlight = true;\n" " try {\n" - " const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n" - " if (!response.ok) throw new Error('status request failed');\n" - " updateStatus(await response.json());\n" - " } catch (_) {\n" - " wifiSummary.textContent = 'Unavailable';\n" - " serialSummary.textContent = 'Unavailable';\n" + " const {payload} = await api('/api/status', generation, {limit: 3072});\n" + " if (live(generation)) updateStatus(payload);\n" + " } catch (error) {\n" + " if (!live(generation)) return;\n" + " wifiSummary.textContent = 'Unavailable'; serialSummary.textContent = 'Unavailable';\n" " brokerClientsField.textContent = '—';\n" - " } finally {\n" - " statusInFlight = false;\n" - " }\n" + " if (error.status === 403) {\n" + " cancelWork(); reconnectEnabled = false; csrf = '';\n" + " setConnection('Request failed', 'bad', error.message + ' Select Connect to reload the session.');\n" + " } else if (error.status) connectionDetail.textContent = error.message;\n" + " } finally { if (live(generation)) statusInFlight = false; }\n" "}\n" "const shutdown = () => {\n" " if (unloading) return;\n" - " unloading = true;\n" - " ++connectionGeneration;\n" - " clearReconnectTimer();\n" - " if (statusTimer !== null) window.clearInterval(statusTimer);\n" + " cancelWork();\n" + " unloading = true; csrf = '';\n" " if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n" + " fitFrame = 0;\n" " if (resizeObserver !== null) resizeObserver.disconnect();\n" - " window.removeEventListener('resize', scheduleFit);\n" - " if (ticketAbort !== null) ticketAbort.abort();\n" - " if (socket !== null) socket.close();\n" - " socket = null;\n" + " updateControls();\n" "};\n" - "window.addEventListener('pagehide', shutdown, {once: true});\n" + "window.addEventListener('pagehide', shutdown);\n" + "window.addEventListener('pageshow', (event) => {\n" + " if (!event.persisted || navigating) return;\n" + " unloading = false; loggingOut = false;\n" + " if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n" + " scheduleFit(); updateControls();\n" + " if (reconnectEnabled && !suspended) connect();\n" + " else {\n" + " const generation = workGeneration;\n" + " const pending = loadSession(generation);\n" + " const sequence = sessionGeneration;\n" + " pending.then((valid) => {\n" + " if (valid && live(generation) && sequence === sessionGeneration && !suspended) startPolling();\n" + " }).catch(() => {\n" + " if (live(generation) && sequence === sessionGeneration) setConnection('Session check failed', 'bad', 'Reload or select Connect to retry.');\n" + " });\n" + " }\n" + "});\n" "updateControls();\n" "scheduleFit();\n" - "pollStatus();\n" - "statusTimer = window.setInterval(pollStatus, 5000);\n" "connect();\n" "})();\n"; @@ -530,8 +714,8 @@ static esp_err_t set_response_headers(httpd_req_t *request, if (result == ESP_OK && response->content_security_policy) { result = httpd_resp_set_hdr( request, "Content-Security-Policy", - "default-src 'none'; script-src 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; " - "script-src-elem 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; " + "default-src 'none'; script-src 'self' 'sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20='; " + "script-src-elem 'self' 'sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20='; " "style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; " "base-uri 'none'; form-action 'none'; " "frame-ancestors 'none'"); diff --git a/tests/admin_ssh_policy/run.py b/tests/admin_ssh_policy/run.py new file mode 100644 index 0000000..a01affb --- /dev/null +++ b/tests/admin_ssh_policy/run.py @@ -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 +#include +#include +#include +#include +#include +#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) diff --git a/tests/web_cookie_auth/README.md b/tests/web_cookie_auth/README.md new file mode 100644 index 0000000..05b5350 --- /dev/null +++ b/tests/web_cookie_auth/README.md @@ -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. diff --git a/tests/web_cookie_auth/run.py b/tests/web_cookie_auth/run.py new file mode 100644 index 0000000..08ab2c8 --- /dev/null +++ b/tests/web_cookie_auth/run.py @@ -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 +#include +#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) diff --git a/tests/web_cookie_auth/test.c b/tests/web_cookie_auth/test.c new file mode 100644 index 0000000..6f81bc8 --- /dev/null +++ b/tests/web_cookie_auth/test.c @@ -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; +} diff --git a/tests/web_login_ui/browser.cjs b/tests/web_login_ui/browser.cjs index 4b67252..ae123e8 100644 --- a/tests/web_login_ui/browser.cjs +++ b/tests/web_login_ui/browser.cjs @@ -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); } }); diff --git a/tests/web_session_store/run.py b/tests/web_session_store/run.py index 699b6fe..2d05018 100644 --- a/tests/web_session_store/run.py +++ b/tests/web_session_store/run.py @@ -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) diff --git a/tests/web_session_store/serial_headers.py b/tests/web_session_store/serial_headers.py index 82df3de..c1b0aab 100644 --- a/tests/web_session_store/serial_headers.py +++ b/tests/web_session_store/serial_headers.py @@ -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); diff --git a/tests/web_session_store/serial_test.c b/tests/web_session_store/serial_test.c index 91d15f5..4d7798e 100644 --- a/tests/web_session_store/serial_test.c +++ b/tests/web_session_store/serial_test.c @@ -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; } diff --git a/tests/web_ui_session/README.md b/tests/web_ui_session/README.md new file mode 100644 index 0000000..5311755 --- /dev/null +++ b/tests/web_ui_session/README.md @@ -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. diff --git a/tests/web_ui_session/browser.cjs b/tests/web_ui_session/browser.cjs new file mode 100644 index 0000000..b94e2c6 --- /dev/null +++ b/tests/web_ui_session/browser.cjs @@ -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: '', 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, /^.*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; }); diff --git a/tests/web_ui_session/run.py b/tests/web_ui_session/run.py new file mode 100644 index 0000000..62212a1 --- /dev/null +++ b/tests/web_ui_session/run.py @@ -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 +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'', 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('