Add allocation-free web auth parsers
Implement strict origin, cookie, and login JSON parsing with fail-closed validation and output wiping. Add focused host contract tests and document the preparatory 8D.3 parser split.
This commit is contained in:
@@ -70,6 +70,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
|
- 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.
|
- 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.
|
- 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.
|
||||||
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
|
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
|
||||||
|
|
||||||
## SSH
|
## SSH
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
|
|||||||
|
|
||||||
## Development state
|
## Development state
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
- **8D.2 validated by user sign-off (2026-09-05):** User explicitly closed 8D.2 after post-flash boot and full-client-mix samples. This supersedes older target-pending/in-progress statements below. Evidence and limits are in `docs/phase8d2_implementation.md`; unrecorded regression details are not claims of execution or blockers to the signed-off checkpoint. Numeric reserves remain open. Wait for a separate 8D.3 request.
|
- **8D.2 validated by user sign-off (2026-09-05):** User explicitly closed 8D.2 after post-flash boot and full-client-mix samples. This supersedes older target-pending/in-progress statements below. Evidence and limits are in `docs/phase8d2_implementation.md`; unrecorded regression details are not claims of execution or blockers to the signed-off checkpoint. Numeric reserves remain open. Wait for a separate 8D.3 request.
|
||||||
|
|
||||||
- **8D.2 target samples received:** User reports post-flash clean boot and full client mix at 115200 baud, recorded in `docs/phase8d2_implementation.md`. Loaded free internal/DMA/PSRAM **39,200 / 31,444 / 8,138,284 B**, minima **18,784 / 11,028 / 8,109,712 B**, SSH stack minimum-free **16,296 B**. Four broker clients confirmed, SSH writer; no reported SSH I/O or web failures. One cumulative SSH broker revocation and an initial console usage error retained without diagnosis. Target validation is in progress, not signed off; lifecycle/revocation/soak/cleanup evidence remains pending. No 8D.3 request.
|
- **8D.2 target samples received:** User reports post-flash clean boot and full client mix at 115200 baud, recorded in `docs/phase8d2_implementation.md`. Loaded free internal/DMA/PSRAM **39,200 / 31,444 / 8,138,284 B**, minima **18,784 / 11,028 / 8,109,712 B**, SSH stack minimum-free **16,296 B**. Four broker clients confirmed, SSH writer; no reported SSH I/O or web failures. One cumulative SSH broker revocation and an initial console usage error retained without diagnosis. Target validation is in progress, not signed off; lifecycle/revocation/soak/cleanup evidence remains pending. No 8D.3 request.
|
||||||
@@ -56,7 +58,16 @@ These observations should be checked when touching the relevant area; they are n
|
|||||||
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
|
- 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.
|
- If HTTPD concurrency configuration changes, add locking around the boot-local Basic-authentication cache.
|
||||||
|
|
||||||
## Active Task - Phase 8D.2 Serial Session Binding
|
## Active Task - Phase 8D.3 Inert Request Parsing
|
||||||
|
|
||||||
|
- **Scope:** Private allocation-free helpers for canonical Host/Origin comparison, unique named 64-lowercase-hex cookie extraction, and strict login JSON. CMake registration plus `tests/web_auth_parse/`; no live route, UI, store, transport, task, socket, stack-size or generated-asset change. Split before coding because full challenge/throttle/routes/UI/tests cutover exceeds the work-unit guideline. No commit, upload or erase.
|
||||||
|
- **Contracts:** Exact bounded byte spans; canonical origin max 128 bytes after optional default-port normalization, ASCII DNS/IPv4 authorities only (IPv6 rejected), mandatory matching HTTPS Origin. Cookie header max 1024 bytes, strict unquoted values even for unrelated cookies. Login body max 512 bytes, exactly username/password strings, decoded limits 16/64 bytes; proper UTF-8/JSON escape/surrogate validation. Unknown/duplicate fields and NUL rejected. Database policy remains authoritative. Failures clear complete outputs; successful credentials and original body require caller wiping. No dynamic allocation or persistent state.
|
||||||
|
- **Validation:** Focused production-C host suite, existing store and `--serial` modes passed. Independent review found no memory-safety defect; default-port maximum-length normalization was fixed and regression cases added. Final rerun/build results recorded in the plan. Host tests are not real HTTPD/browser, sanitizer or target validation.
|
||||||
|
- **Resources:** Build reports **95,260 B linked RAM / 1,601,925 B flash**, unchanged from 8D.2. Unused helpers are not live firmware paths; their future linked flash and request-stack cost must be measured at cutover. No new runtime allocation, task, route, queue or socket. Numeric reserve gates remain pending.
|
||||||
|
- **Next exact work:** Continue 8D.3, not 8D.4: wire the complete challenge/session/login/logout/CSRF/Origin policy and browser recovery atomically, or first take the permitted inert login rendering split if the remaining change still exceeds scope. Never expose partly protected cookie routes. Integration must enforce duplicate HTTP header rejection, bounded complete body reads, content type/method/Fetch Metadata checks, and wipe request buffers on every exit. Bootstrap's permitted missing-Origin GET requires separately validated Host handling, not weakening mutation/upgrade Origin checks. Keep account validation tied to existing database capacities. Remove Basic only with the complete security cutover, then stop for mandatory M1 browser/target sign-off.
|
||||||
|
- **Target checks:** No hardware execution for this inert split. On the live cutover run the full M1 checklist in `docs/phase8d_plan.md` and `docs/phase8d_baseline.md`: both roles, usable errors/logout/account switching, expiry/reboot/revocation/isolation, direct IP/mDNS, capacity/throttle/CSRF, serial reconnect, UART0/USB/user+admin SSH and repeat-cycle/soak memory evidence. No browser-authentication success is claimed here.
|
||||||
|
|
||||||
|
## Previous Task - Phase 8D.2 Serial Session Binding
|
||||||
|
|
||||||
- **Checkpoint/scope:** Clean `93eef0e67641f2672c56692a7785e50f31cf236d`; only `web_serial_transport.{c,h}`, `web_session_store.{c,h}`, one Basic ticket call in `web_server.c`, focused host tests and documentation changed. No upload/erase/commit or generated assets. Existing `user_console` and legacy-sync callers already reach the extended revocation hook.
|
- **Checkpoint/scope:** Clean `93eef0e67641f2672c56692a7785e50f31cf236d`; only `web_serial_transport.{c,h}`, `web_session_store.{c,h}`, one Basic ticket call in `web_server.c`, focused host tests and documentation changed. No upload/erase/commit or generated assets. Existing `user_console` and legacy-sync callers already reach the extended revocation hook.
|
||||||
- **Implementation:** Non-reused 64-bit originating IDs in four tickets/two slots, zero explicitly Basic-only. Matching-ID upgrade and exact principal binding, expiry/currentness at sensitive boundaries and existing periodic owner checks. Session-specific invalidation/cleanup, account-name/all-session invalidation even if serial init failed, reserved-slot revocation and non-wrapping ticket publication epoch. No new task, heap allocation, route, capacity or stack-size change; Basic cache/currentness retained.
|
- **Implementation:** Non-reused 64-bit originating IDs in four tickets/two slots, zero explicitly Basic-only. Matching-ID upgrade and exact principal binding, expiry/currentness at sensitive boundaries and existing periodic owner checks. Session-specific invalidation/cleanup, account-name/all-session invalidation even if serial init failed, reserved-slot revocation and non-wrapping ticket publication epoch. No new task, heap allocation, route, capacity or stack-size change; Basic cache/currentness retained.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 8D — Incremental web administration plan
|
# 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–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.2 implemented, host-tested and build-verified. Reserve budgets remain pending. 8D.3 has an inert request-parser preparatory split; 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.
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
## M2 — Reuse the admin shell, then expose it
|
## M2 — Reuse the admin shell, then expose it
|
||||||
|
|
||||||
### 8D.4 — Small transport-neutral console boundary
|
### 8D.4 — Small transport-neutral console boundary
|
||||||
@@ -159,11 +161,11 @@ Update the roadmap and user/command documentation to distinguish completed featu
|
|||||||
|
|
||||||
## Progress and next-request template
|
## 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–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.2 implemented / host-tested / build-verified. Reserve gates pending. 8D.3 inert parser 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.
|
||||||
|
|
||||||
Suggested next request:
|
Suggested next request:
|
||||||
|
|
||||||
> Phase 8D.2 is validated by user sign-off. Wait for a separate Phase 8D.3 implementation request before cutting over browser authentication; preserve the recorded evidence and open numeric reserve gates.
|
> Continue Phase 8D.3 after its inert request-parser split. Complete login/logout authentication atomically (or split inert login rendering first if needed), preserve 8D.2 sign-off and open numeric reserve gates, and stop for M1 target/browser validation before 8D.4.
|
||||||
|
|
||||||
For later chunks:
|
For later chunks:
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ idf_component_register(
|
|||||||
"web_ui.c"
|
"web_ui.c"
|
||||||
"web_server.c"
|
"web_server.c"
|
||||||
"web_session_store.c"
|
"web_session_store.c"
|
||||||
|
"web_auth_parse.c"
|
||||||
"web_console.c"
|
"web_console.c"
|
||||||
"wifi_config.c"
|
"wifi_config.c"
|
||||||
"wifi_manager.c"
|
"wifi_manager.c"
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
#include "web_auth_parse.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static void wipe(void *buffer, size_t length)
|
||||||
|
{
|
||||||
|
volatile uint8_t *p = buffer;
|
||||||
|
while (length--) *p++ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool alnum_ascii(unsigned char c)
|
||||||
|
{
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= '0' && c <= '9');
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool authority(const char *text, size_t length, char *out)
|
||||||
|
{
|
||||||
|
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
|
||||||
|
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
|
||||||
|
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
|
||||||
|
size_t label = 0;
|
||||||
|
for (size_t i = 0; i < length; ++i) {
|
||||||
|
unsigned char c = (unsigned char)text[i];
|
||||||
|
if (c == '.') {
|
||||||
|
if (!label || text[i - 1U] == '-') return false;
|
||||||
|
label = 0;
|
||||||
|
} else {
|
||||||
|
if (!alnum_ascii(c) && c != '-') return false;
|
||||||
|
if ((!label && c == '-') || ++label > 63U) return false;
|
||||||
|
}
|
||||||
|
out[i] = c >= 'A' && c <= 'Z' ? (char)(c + ('a' - 'A')) : (char)c;
|
||||||
|
}
|
||||||
|
if (!label || text[length - 1U] == '-') return false;
|
||||||
|
out[length] = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||||
|
const char *origin, size_t origin_length,
|
||||||
|
char canonical[WEB_AUTH_ORIGIN_CAPACITY])
|
||||||
|
{
|
||||||
|
if (!canonical) return false;
|
||||||
|
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||||
|
char other[WEB_AUTH_ORIGIN_CAPACITY] = {0};
|
||||||
|
if (!origin || origin_length < 9U || origin_length > WEB_AUTH_ORIGIN_CAPACITY + 3U ||
|
||||||
|
memcmp(origin, "https://", 8U) != 0 ||
|
||||||
|
!authority(host, host_length, canonical + 8U) ||
|
||||||
|
!authority(origin + 8U, origin_length - 8U, other) ||
|
||||||
|
strcmp(canonical + 8U, other) != 0) {
|
||||||
|
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
memcpy(canonical, "https://", 8U);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
{
|
||||||
|
if (!token) return false;
|
||||||
|
memset(token, 0, WEB_AUTH_TOKEN_LENGTH + 1U);
|
||||||
|
if (!header || !name || !*name || !length || length > WEB_AUTH_COOKIE_HEADER_MAX)
|
||||||
|
return false;
|
||||||
|
size_t pos = 0, selected = 0, name_length = strlen(name);
|
||||||
|
bool found = false;
|
||||||
|
while (pos < length) {
|
||||||
|
while (pos < length && header[pos] == ' ') ++pos;
|
||||||
|
size_t start = pos;
|
||||||
|
while (pos < length && cookie_name_char((unsigned char)header[pos])) ++pos;
|
||||||
|
size_t key_length = pos - start;
|
||||||
|
if (!key_length || pos == length || header[pos++] != '=') return false;
|
||||||
|
size_t value = pos;
|
||||||
|
while (pos < length && header[pos] != ';') {
|
||||||
|
unsigned char c = (unsigned char)header[pos++];
|
||||||
|
if (c < 0x21 || c > 0x7e || c == '"' || c == ',' || c == '\\') return false;
|
||||||
|
}
|
||||||
|
if (key_length == name_length && memcmp(header + start, name, key_length) == 0) {
|
||||||
|
if (found || pos - value != WEB_AUTH_TOKEN_LENGTH) return false;
|
||||||
|
for (size_t i = value; i < pos; ++i)
|
||||||
|
if (!((header[i] >= '0' && header[i] <= '9') ||
|
||||||
|
(header[i] >= 'a' && header[i] <= 'f'))) return false;
|
||||||
|
found = true;
|
||||||
|
selected = value;
|
||||||
|
}
|
||||||
|
if (pos < length && ++pos == length) return false;
|
||||||
|
}
|
||||||
|
if (found) memcpy(token, header + selected, WEB_AUTH_TOKEN_LENGTH);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct { const uint8_t *data; size_t length; size_t pos; } json_cursor_t;
|
||||||
|
|
||||||
|
static void whitespace(json_cursor_t *c)
|
||||||
|
{
|
||||||
|
while (c->pos < c->length) {
|
||||||
|
uint8_t b = c->data[c->pos];
|
||||||
|
if (b != ' ' && b != '\t' && b != '\r' && b != '\n') break;
|
||||||
|
++c->pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool take(json_cursor_t *c, uint8_t byte)
|
||||||
|
{
|
||||||
|
whitespace(c);
|
||||||
|
if (c->pos == c->length || c->data[c->pos] != byte) return false;
|
||||||
|
++c->pos;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hex4(json_cursor_t *c, uint32_t *value)
|
||||||
|
{
|
||||||
|
*value = 0;
|
||||||
|
for (unsigned i = 0; i < 4; ++i) {
|
||||||
|
if (c->pos == c->length) return false;
|
||||||
|
uint8_t b = c->data[c->pos++];
|
||||||
|
unsigned digit;
|
||||||
|
if (b >= '0' && b <= '9') digit = b - '0';
|
||||||
|
else if (b >= 'a' && b <= 'f') digit = b - 'a' + 10U;
|
||||||
|
else if (b >= 'A' && b <= 'F') digit = b - 'A' + 10U;
|
||||||
|
else return false;
|
||||||
|
*value = (*value << 4) | digit;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool codepoint(json_cursor_t *c, uint32_t *value)
|
||||||
|
{
|
||||||
|
if (c->pos == c->length) return false;
|
||||||
|
uint8_t b = c->data[c->pos++];
|
||||||
|
if (b == '\\') {
|
||||||
|
if (c->pos == c->length) return false;
|
||||||
|
b = c->data[c->pos++];
|
||||||
|
switch (b) {
|
||||||
|
case '"': case '\\': case '/': *value = b; return true;
|
||||||
|
case 'b': *value = 8; return true;
|
||||||
|
case 'f': *value = 12; return true;
|
||||||
|
case 'n': *value = 10; return true;
|
||||||
|
case 'r': *value = 13; return true;
|
||||||
|
case 't': *value = 9; return true;
|
||||||
|
case 'u': break;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
if (!hex4(c, value)) return false;
|
||||||
|
if (*value >= 0xd800 && *value <= 0xdbff) {
|
||||||
|
uint32_t low;
|
||||||
|
if (c->length - c->pos < 2U || c->data[c->pos++] != '\\' ||
|
||||||
|
c->data[c->pos++] != 'u' || !hex4(c, &low) ||
|
||||||
|
low < 0xdc00 || low > 0xdfff) return false;
|
||||||
|
*value = 0x10000 + ((*value - 0xd800) << 10) + low - 0xdc00;
|
||||||
|
}
|
||||||
|
return *value && !(*value >= 0xd800 && *value <= 0xdfff);
|
||||||
|
}
|
||||||
|
if (b < 0x20) return false;
|
||||||
|
if (b < 0x80) { *value = b; return true; }
|
||||||
|
unsigned extra;
|
||||||
|
uint32_t minimum;
|
||||||
|
if (b >= 0xc2 && b <= 0xdf) { extra = 1; minimum = 0x80; *value = b & 0x1f; }
|
||||||
|
else if (b >= 0xe0 && b <= 0xef) { extra = 2; minimum = 0x800; *value = b & 0x0f; }
|
||||||
|
else if (b >= 0xf0 && b <= 0xf4) { extra = 3; minimum = 0x10000; *value = b & 7; }
|
||||||
|
else return false;
|
||||||
|
while (extra--) {
|
||||||
|
if (c->pos == c->length) return false;
|
||||||
|
b = c->data[c->pos++];
|
||||||
|
if ((b & 0xc0) != 0x80) return false;
|
||||||
|
*value = (*value << 6) | (b & 0x3f);
|
||||||
|
}
|
||||||
|
return *value >= minimum && *value <= 0x10ffff &&
|
||||||
|
!(*value >= 0xd800 && *value <= 0xdfff);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *length)
|
||||||
|
{
|
||||||
|
*length = 0;
|
||||||
|
if (!take(c, '"')) return false;
|
||||||
|
while (c->pos < c->length && c->data[c->pos] != '"') {
|
||||||
|
uint32_t cp;
|
||||||
|
if (!codepoint(c, &cp)) return false;
|
||||||
|
size_t bytes = cp < 0x80 ? 1U : cp < 0x800 ? 2U : cp < 0x10000 ? 3U : 4U;
|
||||||
|
if (bytes > capacity - *length) return false;
|
||||||
|
if (bytes == 1U) out[(*length)++] = (uint8_t)cp;
|
||||||
|
else {
|
||||||
|
out[(*length)++] = (uint8_t)((bytes == 2U ? 0xc0 : bytes == 3U ? 0xe0 : 0xf0) |
|
||||||
|
(cp >> (6U * (bytes - 1U))));
|
||||||
|
for (size_t i = bytes - 1U; i > 0; --i)
|
||||||
|
out[(*length)++] = (uint8_t)(0x80 | ((cp >> (6U * (i - 1U))) & 0x3f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c->pos == c->length) return false;
|
||||||
|
++c->pos;
|
||||||
|
out[*length] = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool web_auth_parse_login(const char *body, size_t length,
|
||||||
|
web_auth_credentials_t *credentials)
|
||||||
|
{
|
||||||
|
if (!credentials) return false;
|
||||||
|
wipe(credentials, sizeof(*credentials));
|
||||||
|
if (!body || !length || length > WEB_AUTH_LOGIN_BODY_MAX) return false;
|
||||||
|
json_cursor_t c = { (const uint8_t *)body, length, 0 };
|
||||||
|
unsigned seen = 0;
|
||||||
|
if (!take(&c, '{')) return false;
|
||||||
|
for (unsigned field = 0; field < 2; ++field) {
|
||||||
|
uint8_t key[9] = {0};
|
||||||
|
size_t key_length;
|
||||||
|
if ((field && !take(&c, ',')) || !string(&c, key, 8U, &key_length) ||
|
||||||
|
!take(&c, ':')) goto invalid;
|
||||||
|
unsigned bit;
|
||||||
|
uint8_t *output;
|
||||||
|
size_t *output_length, capacity;
|
||||||
|
if (key_length == 8U && memcmp(key, "username", 8U) == 0) {
|
||||||
|
bit = 1; output = credentials->username;
|
||||||
|
output_length = &credentials->username_length; capacity = WEB_AUTH_USERNAME_MAX;
|
||||||
|
} else if (key_length == 8U && memcmp(key, "password", 8U) == 0) {
|
||||||
|
bit = 2; output = credentials->password;
|
||||||
|
output_length = &credentials->password_length; capacity = WEB_AUTH_PASSWORD_MAX;
|
||||||
|
} else goto invalid;
|
||||||
|
if ((seen & bit) || !string(&c, output, capacity, output_length)) goto invalid;
|
||||||
|
seen |= bit;
|
||||||
|
}
|
||||||
|
if (!take(&c, '}')) goto invalid;
|
||||||
|
whitespace(&c);
|
||||||
|
if (c.pos == c.length && seen == 3U) return true;
|
||||||
|
invalid:
|
||||||
|
wipe(credentials, sizeof(*credentials));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
/* Private, allocation-free parsing only: these helpers do not authorize requests. */
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#define WEB_AUTH_ORIGIN_CAPACITY 129U
|
||||||
|
#define WEB_AUTH_COOKIE_HEADER_MAX 1024U
|
||||||
|
#define WEB_AUTH_TOKEN_LENGTH 64U
|
||||||
|
#define WEB_AUTH_LOGIN_BODY_MAX 512U
|
||||||
|
#define WEB_AUTH_USERNAME_MAX 16U
|
||||||
|
#define WEB_AUTH_PASSWORD_MAX 64U
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
size_t username_length;
|
||||||
|
size_t password_length;
|
||||||
|
uint8_t username[WEB_AUTH_USERNAME_MAX + 1U];
|
||||||
|
uint8_t password[WEB_AUTH_PASSWORD_MAX + 1U];
|
||||||
|
} web_auth_credentials_t;
|
||||||
|
|
||||||
|
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
|
||||||
|
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
|
||||||
|
* IPv6 literals are deliberately rejected until the device supports that route.
|
||||||
|
* Only optional :443 is accepted. Origin is mandatory and must match Host.
|
||||||
|
* HTTP callers must separately reject duplicate header lines, enforce methods,
|
||||||
|
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
|
||||||
|
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||||
|
const char *origin, size_t origin_length,
|
||||||
|
char canonical[WEB_AUTH_ORIGIN_CAPACITY]);
|
||||||
|
/* Extract exactly one named lowercase-hex token; malformed/duplicate or missing
|
||||||
|
* selected cookie fails. Other cookies are syntax-checked but not retained.
|
||||||
|
* This deliberately accepts only unquoted cookie values, including unrelated
|
||||||
|
* cookies; quoted values fail closed. No whitespace inside a cookie pair.
|
||||||
|
* 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]);
|
||||||
|
/* 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
|
||||||
|
* original request body and successful credentials using secure_wipe(). */
|
||||||
|
bool web_auth_parse_login(const char *body, size_t length,
|
||||||
|
web_auth_credentials_t *credentials);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Web authentication parser host tests
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tests/web_auth_parse/run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Python 3 (standard library only) and a host `cc` supporting shared
|
||||||
|
libraries. The runner compiles the actual `src/web_auth_parse.c` with
|
||||||
|
`-std=c11 -Wall -Wextra -Werror -shared -fPIC` into a temporary directory, loads
|
||||||
|
it with `ctypes`, and removes build artifacts on exit. No firmware dependencies,
|
||||||
|
parser substitutes, network access, or persistent build artifacts are used.
|
||||||
|
Compilation errors and test failures produce nonzero exit status.
|
||||||
|
|
||||||
|
Tables cover DNS/IPv4 origin canonicalization, case folding and optional `:443`,
|
||||||
|
malformed authorities/origins and unsupported IPv6; selected cookie presence,
|
||||||
|
uniqueness, exact lowercase 64-digit hex and surrounding cookie syntax; strict
|
||||||
|
login JSON, both field orders, escapes, Unicode/surrogate pairs, invalid UTF-8,
|
||||||
|
NUL, duplicate/unknown fields, truncation and byte limits (512-byte body,
|
||||||
|
16-byte username, 64-byte password). Boundary cases include canonical origin
|
||||||
|
capacity and the 1024-byte Cookie header limit.
|
||||||
|
|
||||||
|
Inputs use exact byte spans without implicit terminators and never alias
|
||||||
|
outputs. Each output is first filled with `0xA5`; every failed call must clear
|
||||||
|
**all** output bytes, including credential structure padding and unused array
|
||||||
|
bytes. Successful results check canonical/decoded bytes and termination.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- This is a focused parser contract suite, not HTTP integration, authorization,
|
||||||
|
CSRF/session, duplicate HTTP header-line, TLS, credential-policy, or hardware
|
||||||
|
testing. Empty credentials are syntactically valid; database policy is separate.
|
||||||
|
- IPv6 is intentionally rejected, not normalized or supported.
|
||||||
|
- Python mirrors the public C struct and capacities; interface changes must
|
||||||
|
update these tests. Shared-library loading assumes a Unix-like host/compiler.
|
||||||
|
- Tables are not exhaustive fuzzing, memory-safety instrumentation, or proof of
|
||||||
|
constant-time behavior. Output wiping is observed after return; this does not
|
||||||
|
establish erasure of internal temporaries or successful credentials. Test
|
||||||
|
credentials/tokens are synthetic, not secrets.
|
||||||
|
- NULL output pointers and overlapping buffers are not exercised: valid,
|
||||||
|
disjoint output storage is provided for every call.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dependency-free contract tests against the production parser, not a model."""
|
||||||
|
import ctypes as C
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class Credentials(C.Structure):
|
||||||
|
_fields_ = [("username_length", C.c_size_t), ("password_length", C.c_size_t),
|
||||||
|
("username", C.c_uint8 * 17), ("password", C.c_uint8 * 65)]
|
||||||
|
|
||||||
|
|
||||||
|
def span(value):
|
||||||
|
# No implicit NUL terminator; keep the allocation alive throughout the call.
|
||||||
|
return None if value is None else (C.c_char * max(1, len(value))).from_buffer_copy(value or b"\0")
|
||||||
|
|
||||||
|
|
||||||
|
def size(value):
|
||||||
|
return 0 if value is None else len(value)
|
||||||
|
|
||||||
|
|
||||||
|
def login(username=b"u", password=b"p"):
|
||||||
|
return b'{"username":"' + username + b'","password":"' + password + b'"}'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory(prefix="web-auth-parse-") as temporary:
|
||||||
|
library = Path(temporary) / "parser.so"
|
||||||
|
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||||
|
"-shared", "-fPIC", "-I", str(ROOT / "src"),
|
||||||
|
str(ROOT / "src/web_auth_parse.c"), "-o", str(library)], check=True)
|
||||||
|
api = C.CDLL(str(library))
|
||||||
|
api.web_auth_parse_origin.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_void_p]
|
||||||
|
api.web_auth_parse_cookie.argtypes = [C.c_void_p, C.c_size_t, C.c_char_p, C.c_void_p]
|
||||||
|
api.web_auth_parse_login.argtypes = [C.c_void_p, C.c_size_t, C.POINTER(Credentials)]
|
||||||
|
for name in ("origin", "cookie", "login"):
|
||||||
|
getattr(api, "web_auth_parse_" + name).restype = C.c_bool
|
||||||
|
failures, count = [], 0
|
||||||
|
|
||||||
|
def check(kind, label, invoke, output, expected, extract):
|
||||||
|
nonlocal count
|
||||||
|
count += 1
|
||||||
|
C.memset(C.addressof(output), 0xA5, C.sizeof(output))
|
||||||
|
result = invoke()
|
||||||
|
raw = C.string_at(C.addressof(output), C.sizeof(output))
|
||||||
|
if result != (expected is not None):
|
||||||
|
failures.append(f"{kind}: {label}: unexpected success={result}")
|
||||||
|
if not result and raw != bytes(len(raw)):
|
||||||
|
failures.append(f"{kind}: {label}: failure did not wipe every output byte")
|
||||||
|
if result and expected is not None and extract(output) != expected:
|
||||||
|
failures.append(f"{kind}: {label}: incorrect decoded/canonical output")
|
||||||
|
|
||||||
|
origins = [(b"EXAMPLE.Com", b"https://example.com", b"https://example.com"),
|
||||||
|
(b"a-b.local", b"https://A-B.LOCAL", b"https://a-b.local"),
|
||||||
|
(b"localhost", b"https://localhost", b"https://localhost"),
|
||||||
|
(b"192.168.1.1", b"https://192.168.1.1", b"https://192.168.1.1")]
|
||||||
|
for host in (b"EXAMPLE.COM", b"192.168.1.1"):
|
||||||
|
for hp in (b"", b":443"):
|
||||||
|
for op in (b"", b":443"):
|
||||||
|
origins.append((host + hp, b"https://" + host.lower() + op,
|
||||||
|
b"https://" + host.lower()))
|
||||||
|
bad_hosts = [b"", b" ", b"example.com ", b" example.com", b"a..b", b".a", b"a.",
|
||||||
|
b"-a", b"a-", b"a_b", b"a/b", b"a?b", b"a#b", b"u@a", b"a,b",
|
||||||
|
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"[::1]", b"::1",
|
||||||
|
b"[::1]:443", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
|
||||||
|
b"a:443:443", b"a" * 64 + b".com", b"a" * 130]
|
||||||
|
origins += [(h, b"https://" + h, None) for h in bad_hosts]
|
||||||
|
origins += [(b"example.com", o, None) for o in
|
||||||
|
(None, b"", b"null", b"http://example.com", b"https://other.com",
|
||||||
|
b"https://example.com/", b"https://example.com/path", b"https://example.com?x",
|
||||||
|
b"https://example.com#x", b"https://u@example.com", b"https://example.com:80",
|
||||||
|
b"https://example.com:0443", b" https://example.com", b"https://example.com ",
|
||||||
|
b"https://example.com\0", b"https://example.com\r\n", b"https://[::1]",
|
||||||
|
b"https://example.com https://example.com", b"https:///example.com")]
|
||||||
|
origins += [(None, b"https://example.com", None)]
|
||||||
|
# 128 canonical bytes fit with the terminator; 129 do not.
|
||||||
|
for n in (56, 57):
|
||||||
|
host = b"a" * 63 + b"." + b"b" * n
|
||||||
|
for hp in (b"", b":443"):
|
||||||
|
for op in (b"", b":443"):
|
||||||
|
origins.append((host + hp, b"https://" + host + op,
|
||||||
|
b"https://" + host if n == 56 else None))
|
||||||
|
for index, (host, origin, expected) in enumerate(origins):
|
||||||
|
h, o, out = span(host), span(origin), C.create_string_buffer(129)
|
||||||
|
check("origin", str(index), lambda: api.web_auth_parse_origin(h, size(host), o, size(origin), out),
|
||||||
|
out, expected, lambda x: x.value)
|
||||||
|
|
||||||
|
token = b"0123456789abcdef" * 4
|
||||||
|
selected = b"sid=" + token
|
||||||
|
cookies = [(selected, token), (b"x=1; " + selected + b"; y=two", token),
|
||||||
|
(b"sid2=other; " + selected, token), (b"SID=other; " + selected, token),
|
||||||
|
(b"empty=; " + selected, token)]
|
||||||
|
cookies += [(h, None) for h in
|
||||||
|
(None, b"", b"x=1", b"SID=" + token, b"sid2=" + token,
|
||||||
|
selected + b"; " + selected, selected + b"; sid=bad", b"sid=bad; " + selected,
|
||||||
|
b"sid=" + token[:-1], selected + b"0", b"sid=" + token.upper(),
|
||||||
|
b"sid=" + b"g" * 64, b'sid="' + token + b'"', b"sid=", b"sid",
|
||||||
|
selected + b"; broken", b"broken; " + selected, selected + b"; =x",
|
||||||
|
selected + b"; bad name=x", selected + b"; x=bad,value",
|
||||||
|
selected + b"; x=bad\\value", selected + b"\r\n", selected + b"\0",
|
||||||
|
selected + b"; x=\x01", selected + b"; x=\x7f", selected + b"; x=\xff")]
|
||||||
|
for length in (1024, 1025):
|
||||||
|
cookies.append((selected + b"; x=" + b"a" * (length - len(selected) - 4),
|
||||||
|
token if length == 1024 else None))
|
||||||
|
for index, (header, expected) in enumerate(cookies):
|
||||||
|
h, out = span(header), C.create_string_buffer(65)
|
||||||
|
check("cookie", str(index), lambda: api.web_auth_parse_cookie(h, size(header), b"sid", out),
|
||||||
|
out, expected, lambda x: x.value)
|
||||||
|
|
||||||
|
cases = [(login(), (b"u", b"p")), (login(b"", b""), (b"", b"")),
|
||||||
|
(b' \r\n\t{ "password" : "p", "username" : "u" } \t', (b"u", b"p")),
|
||||||
|
(b'{"user\\u006eame":"u","pass\\u0077ord":"p"}', (b"u", b"p")),
|
||||||
|
(login(b'\\"\\\\\\/\\b\\f\\n\\r\\t', b"\\u0041"), (b'"\\/\b\f\n\r\t', b"A")),
|
||||||
|
(login(b"\\u00e9", b"\\ud83d\\ude00"), ("é".encode(), "😀".encode()))]
|
||||||
|
for text in ("é", "€", "😀", "\U0010ffff"):
|
||||||
|
for ascii_only in (True, False):
|
||||||
|
body = json.dumps({"username": text, "password": text}, ensure_ascii=ascii_only).encode()
|
||||||
|
cases.append((body, (text.encode(), text.encode())))
|
||||||
|
for n in (15, 16, 17):
|
||||||
|
cases.append((login(b"a" * n), (b"a" * n, b"p") if n <= 16 else None))
|
||||||
|
cases.append((login(b"\\u0061" * n), (b"a" * n, b"p") if n <= 16 else None))
|
||||||
|
for n in (63, 64, 65):
|
||||||
|
cases.append((login(password=b"a" * n), (b"u", b"a" * n) if n <= 64 else None))
|
||||||
|
cases.append((login(password=b"\\u0061" * n), (b"u", b"a" * n) if n <= 64 else None))
|
||||||
|
for field, limit in (("username", 16), ("password", 64)):
|
||||||
|
for extra in (b"", b"a"):
|
||||||
|
value = "😀".encode() * (limit // 4) + extra
|
||||||
|
u, p = (value, b"p") if field == "username" else (b"u", value)
|
||||||
|
cases.append((login(u, p), (u, p) if not extra else None))
|
||||||
|
for n in (511, 512, 513):
|
||||||
|
cases.append((login() + b" " * (n - len(login())), (b"u", b"p") if n <= 512 else None))
|
||||||
|
bad_json = [None, b"", b"{}", b"[]", b"null", b'{"username":"u"}', b'{"password":"p"}',
|
||||||
|
login() + b"x", login() + login(), b"\xef\xbb\xbf" + login(),
|
||||||
|
login()[:-1] + b',}', login()[:-1] + b',"extra":"x"}',
|
||||||
|
login()[:-1] + b',"username":"v"}', login()[:-1] + b',"password":"q"}',
|
||||||
|
login()[:-1] + b',"user\\u006eame":"v"}',
|
||||||
|
b'{"username":1,"password":"p"}', b'{"username":"u","password":null}',
|
||||||
|
b'{"username":[],"password":"p"}', b'{"username":"u" "password":"p"}',
|
||||||
|
b"{'username':'u','password':'p'}", b'/*x*/' + login()]
|
||||||
|
for value in (b"\0", b"\\u0000", b"\\x41", b"\\q", b"\\u123", b"\\uZZZZ",
|
||||||
|
b"\\ud800", b"\\udc00", b"\\ud800\\u0041", b"\\udc00\\ud800",
|
||||||
|
b"\x80", b"\xc0\xaf", b"\xc1\xbf", b"\xc2", b"\xe2\x82", b"\xf0\x9f\x98",
|
||||||
|
b"\xe0\x80\x80", b"\xed\xa0\x80", b"\xf0\x80\x80\x80",
|
||||||
|
b"\xf4\x90\x80\x80", b"\xf5\x80\x80\x80", b"\xff", b"\xc2A"):
|
||||||
|
bad_json.extend((login(value), login(password=value)))
|
||||||
|
bad_json += [login(bytes([n])) for n in range(1, 32)]
|
||||||
|
# Every truncated prefix of a valid document must fail, including late failures.
|
||||||
|
bad_json += [login()[:n] for n in range(len(login()))]
|
||||||
|
bad_json += [login() + b"\0", b'{"user\xffname":"u","password":"p"}']
|
||||||
|
cases += [(body, None) for body in bad_json]
|
||||||
|
|
||||||
|
def decoded(out):
|
||||||
|
u, p = out.username_length, out.password_length
|
||||||
|
if u > 16 or p > 64 or out.username[u] or out.password[p]:
|
||||||
|
return "invalid lengths or missing terminator"
|
||||||
|
return bytes(out.username[:u]), bytes(out.password[:p])
|
||||||
|
|
||||||
|
for index, (body, expected) in enumerate(cases):
|
||||||
|
b, out = span(body), Credentials()
|
||||||
|
check("login", str(index), lambda: api.web_auth_parse_login(b, size(body), C.byref(out)),
|
||||||
|
out, expected, decoded)
|
||||||
|
for failure in failures:
|
||||||
|
print("FAIL:", failure)
|
||||||
|
print(f"{count} cases; {len(failures)} failures")
|
||||||
|
return bool(failures)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user