From 3ac9549cc3cee52ebf4a002b3c7cc836f1f2dd79 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 13 Jul 2026 21:31:34 +0200 Subject: [PATCH] fix(security): bound untrusted wire decoding req: security/005 --- PLAN.md | 2 +- examples/kanban/static/sync.js | 38 ++++++++- examples/kanban/tests/browser_e2e.rs | 119 +++++++++++++++++++++++++++ hemx-js/runtime/hemx.js | 94 ++++++++++++++++----- hemx-wasm/src/lib.rs | 31 ++++++- hemx-wasm/tests/browser.rs | 75 +++++++++++++++++ 6 files changed, 335 insertions(+), 24 deletions(-) diff --git a/PLAN.md b/PLAN.md index 90f6e69..cb432c2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,7 +46,7 @@ encryption, retention, backup, and deployment policy remain host concerns. - **Build:** materialize `hemx-sync` over an integration transport with idempotent server command processing, snapshot/change cursor, durable acknowledgements, bounded ordered replay, current auth checks, rejection/conflict results, canonical replacement, reconnect jitter/backoff, multi-tab coordination, and redacted diagnostics. - **Refusals:** no default CRDT, transport in core, cached enqueue-time permission, unbounded queue, or silent last-write-wins policy. - **Requirements:** `sync/001-023`, `operations/001-005`, `security/002-005`, `performance/004-005`. -- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. Slice 4 remains open on `operations/004` bounded liveness/slow-consumer proof, the non-sync handler/background-replay legs of `operations/003`, `security/003` host enforcement plus request media/size boundaries, and `security/005` adversarial decoder coverage. The next coherent slice is `security/005`: prove malformed effect/state/event/command/sync inputs—including unknown versions, invalid kinds, oversized lengths, truncation, and trailing data—are rejected before any partial application. +- **Proof:** `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` proves `sync/002` and `sync/003` through generated real WASM: `SyncEffect::send_patch` travels beside ordinary DOM effects, the strict flat scalar schema is accepted, the offline interaction performs zero patch requests while remaining durable, and reconnect uploads with identical opaque operation/idempotency identity before matching acknowledgement removes it. `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 1–3 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. `cargo test -p hemx-kanban-example --test browser_e2e identical_sync_inputs_reconcile_deterministically -- --exact` proves `sync/022` through a real browser and live sync endpoints: one accepted server result and canonical snapshot plus an ordered command sequence produce byte-equivalent structured reconciliation records on independent cloned inputs, do not mutate inputs, and select the explicit server-authoritative model. `cargo test -p hemx-kanban-example --test browser_e2e sync_requests_timeout_and_cancel_on_pagehide -- --exact` proves the sync request boundary enforces a named finite timeout and reports `TimeoutError`, while page teardown cancels a pending request with `AbortError`, closes the acknowledgement stream, and clears retry/lease timers. Per-requirement audit: every `sync/001-023` row now has concrete browser proof; existing live-browser proofs also cover `operations/001-002`, `operations/005`, `security/002`, `security/004`, `performance/004`, and `performance/005`, while this slice closes the sync request/stream leg of `operations/003`. `cargo test -p hemx-kanban-example --test browser_e2e adversarial_wire_inputs_are_rejected_before_partial_application -- --exact` proves malformed, oversized, truncated, trailing, unknown-version, and invalid-kind batch/state/command inputs fail without UI mutation; `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally proves unknown-version and oversized event/state inputs are rejected at the generated real-WASM handler boundary before application code. The runtime now bounds total payloads, fields, vectors, varints, UTF-8, enums, and option discriminants before allocation or dispatch, and the durable queue validates every loaded command before replay, closing `security/005`. Slice 4 remains open on `operations/004` bounded liveness/slow-consumer proof, the non-sync handler/background-replay legs of `operations/003`, and `security/003` host enforcement plus request media/size boundaries. The next coherent slice is `security/003`: enforce and prove a host-configured request body limit and media-type rejection at the mutation boundary. ## Slice 5 — local-first multiplayer Kanban milestone diff --git a/examples/kanban/static/sync.js b/examples/kanban/static/sync.js index 32e47f9..2fca4f1 100644 --- a/examples/kanban/static/sync.js +++ b/examples/kanban/static/sync.js @@ -117,12 +117,48 @@ async function openLog() { return requestResult(request); } +export function validateQueuedCommand(command) { + if (!command || Object.getPrototypeOf(command) !== Object.prototype) { + throw new TypeError("queued command must be an object"); + } + if (command.schemaVersion !== COMMAND_SCHEMA) { + throw new RangeError(`unsupported queued command schema version ${command.schemaVersion}`); + } + const boundedString = (field, maximum) => { + const value = command[field]; + if (typeof value !== "string" || value.length === 0 || value.length > maximum) { + throw new TypeError(`queued command ${field} is invalid`); + } + }; + boundedString("id", 256); + boundedString("accountPartition", 128); + boundedString("actor", 128); + boundedString("session", 128); + boundedString("cardId", 128); + if (!Number.isSafeInteger(command.causal) || command.causal < 1) { + throw new TypeError("queued command causal is invalid"); + } + const queuedAt = command.queuedAt === undefined ? 0 : command.queuedAt; + if (!Number.isSafeInteger(queuedAt) || queuedAt < 0) { + throw new TypeError("queued command queuedAt is invalid"); + } + if (command.kind !== "reorder_card") throw new TypeError(`unknown queued command kind ${command.kind}`); + if (command.targetColumn !== "done") throw new TypeError(`unknown queued command target ${command.targetColumn}`); + if (!["click", "drop", "keydown"].includes(command.eventKind)) { + throw new TypeError(`unknown queued command event kind ${command.eventKind}`); + } + if (command.key !== null && (typeof command.key !== "string" || command.key.length > 64)) { + throw new TypeError("queued command key is invalid"); + } + return command.queuedAt === undefined ? { ...command, queuedAt } : command; +} + async function pendingCommands(database) { const transaction = database.transaction(COMMANDS, "readonly"); const done = transactionDone(transaction); const commands = await requestResult(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition)); await done; - return commands.sort((left, right) => left.causal - right.causal); + return commands.map(validateQueuedCommand).sort((left, right) => left.causal - right.causal); } async function removePendingCommand(database, commandId) { diff --git a/examples/kanban/tests/browser_e2e.rs b/examples/kanban/tests/browser_e2e.rs index e2c8478..6f90ae3 100644 --- a/examples/kanban/tests/browser_e2e.rs +++ b/examples/kanban/tests/browser_e2e.rs @@ -2077,6 +2077,125 @@ async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> Web result.and(quit) } +#[tokio::test] +async fn adversarial_wire_inputs_are_rejected_before_partial_application() -> WebDriverResult<()> { + // test req: security/005 + let app_port = available_port(); + let app_addr = format!("127.0.0.1:{app_port}"); + let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); + app_command.env("HEMX_KANBAN_ADDR", &app_addr); + let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) + .expect("start hemx-kanban"); + + let webdriver_port = available_port(); + let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); + let mut webdriver = Command::new("geckodriver"); + webdriver.arg("--port").arg(webdriver_port.to_string()); + let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) + .expect("start ready geckodriver"); + let mut caps = DesiredCapabilities::firefox(); + caps.set_headless()?; + let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; + + let result = async { + driver.goto(&format!("http://{app_addr}/sync-demo")).await?; + wait_until( + &driver, + "const root = document.querySelector('[data-kanban-sync]'); return window.hemx && root?.getAttribute('data-sync-phase') === 'idle'", + ) + .await?; + let proof = driver + .execute_async( + r#" + const done = arguments[arguments.length - 1]; + (async () => { + const root = document.querySelector('[data-kanban-sync]'); + const before = root.outerHTML; + const validEmpty = new Uint8Array([ + 72, 69, 77, 88, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, + ]); + const invalidKind = new Uint8Array([...validEmpty.slice(0, 16), 1, 0, 0, 0, 255]); + const unknownVersion = validEmpty.slice(); + unknownVersion[4] = 99; + const cases = [ + ["malformed", new Uint8Array([0, 1, 2, 3])], + ["truncated", validEmpty.slice(0, -1)], + ["trailing", new Uint8Array([...validEmpty, 1])], + ["unknown-version", unknownVersion], + ["invalid-kind", invalidKind], + ["oversized", new Uint8Array(1024 * 1024 + 1)], + ]; + const batchErrors = Object.fromEntries(cases.map(([name, bytes]) => { + try { + window.hemx.decodeBatch(bytes.buffer); + return [name, null]; + } catch (error) { + return [name, String(error)]; + } + })); + const stateErrors = {}; + for (const [name, encoded] of [ + ["truncated", "AQ"], + ["trailing", "AAA"], + ["oversized", "A".repeat(Math.ceil((1024 * 1024) * 4 / 3) + 8)], + ]) { + try { + window.hemx.decodeAtomState(encoded); + stateErrors[name] = null; + } catch (error) { + stateErrors[name] = String(error); + } + } + const { validateQueuedCommand } = await import('/sync.js'); + const command = { + id: 'actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'actor', + session: 'session', causal: 1, queuedAt: 1, kind: 'reorder_card', cardId: '1', + targetColumn: 'done', eventKind: 'click', key: null, + }; + const commandErrors = {}; + for (const [name, candidate] of [ + ["unknown-version", { ...command, schemaVersion: 99 }], + ["invalid-kind", { ...command, kind: 'execute_script' }], + ["oversized-id", { ...command, id: 'x'.repeat(257) }], + ]) { + try { + validateQueuedCommand(candidate); + commandErrors[name] = null; + } catch (error) { + commandErrors[name] = String(error); + } + } + done({ + batchErrors, + stateErrors, + commandErrors, + unchanged: before === root.outerHTML, + }); + })().catch((error) => done({ error: String(error), stack: error?.stack })); + "#, + Vec::new(), + ) + .await? + .json() + .clone(); + assert!(proof["error"].is_null(), "adversarial proof failed: {proof}"); + for group in ["batchErrors", "stateErrors", "commandErrors"] { + let errors = proof[group].as_object().expect("error group"); + assert!( + errors.values().all(|error| error.as_str().is_some_and(|message| !message.is_empty())), + "{group} accepted an adversarial input: {proof}" + ); + } + assert_eq!(proof["unchanged"], true, "input rejection mutated the UI: {proof}"); + Ok(()) + } + .await; + let quit = driver.quit().await; + result.and(quit) +} + #[tokio::test] async fn sync_requests_timeout_and_cancel_on_pagehide() -> WebDriverResult<()> { // test req: operations/003 diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 102e8da..23cbebe 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -406,10 +406,15 @@ } function applyBatch(buffer, root) { - const batch = decodeBatch(buffer); - if (batch.abiVersion !== runtimeAbiVersion) { - location.reload(); - return; + let batch; + try { + batch = decodeBatch(buffer); + } catch (error) { + if (error?.code === "HEMX_UNSUPPORTED_ABI") { + location.reload(); + return; + } + throw error; } const expected = root && root.getAttribute(FINGERPRINT); if (expected && String(batch.fingerprint) !== expected) { @@ -901,14 +906,30 @@ } function base64UrlBytes(encoded) { - const normalized = String(encoded).replace(/-/g, "+").replace(/_/g, "/"); + if (typeof encoded !== "string" || encoded.length > Math.ceil(MAX_WIRE_BYTES * 4 / 3) + 4) { + throw new Error(`encoded hemx state exceeds ${MAX_WIRE_BYTES} bytes`); + } + const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4); return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0)); } + const MAX_WIRE_BYTES = 1024 * 1024; + const MAX_WIRE_ITEMS = 1024; + const MAX_WIRE_FIELD_BYTES = 256 * 1024; + + function boundedLength(value, maximum, field) { + if (!Number.isSafeInteger(value) || value < 0 || value > maximum) { + throw new Error(`${field} length ${value} exceeds ${maximum}`); + } + return value; + } + function postcardDecoder(bytes) { + if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx state exceeds ${MAX_WIRE_BYTES} bytes`); let offset = 0; const need = (len) => { + boundedLength(len, bytes.length - offset, "hemx state field"); const end = offset + len; if (end > bytes.length) throw new Error("truncated hemx state"); const slice = bytes.subarray(offset, end); @@ -916,24 +937,31 @@ return slice; }; const varint = () => { - let shift = 0; let value = 0; - for (;;) { + for (let index = 0; index < 5; index += 1) { const byte = need(1)[0]; - value |= (byte & 0x7f) << shift; + if (index === 4 && byte > 0x0f) throw new Error("oversized hemx state varint"); + value += (byte & 0x7f) * (2 ** (index * 7)); if ((byte & 0x80) === 0) return value >>> 0; - shift += 7; } + throw new Error("oversized hemx state varint"); + }; + const bytesField = () => need(boundedLength(varint(), MAX_WIRE_FIELD_BYTES, "hemx state bytes")); + const vec = (read) => { + const length = boundedLength(varint(), MAX_WIRE_ITEMS, "hemx state vector"); + const values = []; + for (let index = 0; index < length; index += 1) values.push(read()); + return values; }; - const bytesField = () => need(varint()); - const vec = (read) => Array.from({ length: varint() }, read); return { varint, bytes: bytesField, vec, done: () => offset === bytes.length }; } function decoder(buffer) { const bytes = new Uint8Array(buffer); + if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx batch exceeds ${MAX_WIRE_BYTES} bytes`); let offset = 0; const need = (len) => { + boundedLength(len, bytes.length - offset, "hemx batch field"); const end = offset + len; if (end > bytes.length) throw new Error("truncated hemx batch"); const slice = bytes.subarray(offset, end); @@ -950,21 +978,36 @@ const hi = BigInt(u32()); return lo | (hi << 32n); }; - const str = () => new TextDecoder().decode(need(u32())); - const option = (read) => u8() === 0 ? null : read(); - const resource = () => ({ kind: ["slot", "atom", "handle", "form"][u8()], id: u32() }); + const str = () => new TextDecoder("utf-8", { fatal: true }).decode( + need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx string")), + ); + const enumValue = (values, field) => { + const discriminant = u8(); + if (discriminant >= values.length) throw new Error(`unknown ${field} ${discriminant}`); + return values[discriminant]; + }; + const option = (read) => { + const discriminant = u8(); + if (discriminant === 0) return null; + if (discriminant === 1) return read(); + throw new Error(`unknown hemx option ${discriminant}`); + }; + const resource = () => ({ kind: enumValue(["slot", "atom", "handle", "form"], "hemx resource"), id: u32() }); const scope = () => { const kind = u8(); if (kind === 0) return null; - return { kind: kind === 1 ? "key" : "field", value: str() }; + if (kind === 1) return { kind: "key", value: str() }; + if (kind === 2) return { kind: "field", value: str() }; + throw new Error(`unknown hemx scope ${kind}`); }; const ref = () => ({ resource: resource(), scope: scope() }); - const payload = () => ({ kind: u8() === 0 ? "text" : "html", value: str() }); + const payload = () => ({ kind: enumValue(["text", "html"], "hemx payload"), value: str() }); const scroll = () => { const kind = u8(); if (kind === 0) return "preserve"; if (kind === 1) return "top"; - return { kind: "element", target: ref() }; + if (kind === 2) return { kind: "element", target: ref() }; + throw new Error(`unknown hemx scroll behavior ${kind}`); }; const effect = () => { const kind = u8(); @@ -974,18 +1017,29 @@ if (kind === 3) return { kind: "remove", target: ref(), key: option(str) }; if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) }; if (kind === 5) return { kind: "focus", target: ref() }; - if (kind === 6) return { kind: "navigate", url: str(), mode: ["push", "replace", "redirect"][u8()], scroll: scroll(), title: option(str) }; + if (kind === 6) return { kind: "navigate", url: str(), mode: enumValue(["push", "replace", "redirect"], "hemx navigation mode"), scroll: scroll(), title: option(str) }; if (kind === 7) return { kind: "emit", name: str(), payload: str() }; throw new Error(`unknown hemx effect ${kind}`); }; - const vec = (read) => Array.from({ length: u32() }, read); + const vec = (read) => { + const length = boundedLength(u32(), MAX_WIRE_ITEMS, "hemx effect vector"); + const values = []; + for (let index = 0; index < length; index += 1) values.push(read()); + return values; + }; return { u8, u32, u64, vec, effect, done: () => offset === bytes.length }; } function decodeBatch(buffer) { const d = decoder(buffer); if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "HEMX") throw new Error("bad hemx batch magic"); - const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) }; + const abiVersion = d.u32(); + if (abiVersion !== runtimeAbiVersion) { + const error = new Error(`unsupported hemx batch ABI version ${abiVersion}; expected ${runtimeAbiVersion}`); + error.code = "HEMX_UNSUPPORTED_ABI"; + throw error; + } + const batch = { abiVersion, fingerprint: d.u64(), ops: d.vec(d.effect) }; if (!d.done()) throw new Error("trailing hemx batch bytes"); return batch; } diff --git a/hemx-wasm/src/lib.rs b/hemx-wasm/src/lib.rs index 34c9df0..d5e2952 100644 --- a/hemx-wasm/src/lib.rs +++ b/hemx-wasm/src/lib.rs @@ -12,6 +12,10 @@ pub use wasm_bindgen::*; pub const CLIENT_EVENT_ABI_VERSION: u32 = 1; pub const CLIENT_STATE_ABI_VERSION: u32 = 1; +const MAX_CLIENT_EVENT_KIND_BYTES: usize = 256; +const MAX_CLIENT_EVENT_VALUE_BYTES: usize = 64 * 1024; +const MAX_CLIENT_EVENT_KEY_BYTES: usize = 1024; +const MAX_CLIENT_STATE_BYTES: usize = 1024 * 1024; /// Versioned browser event accepted by client-local handlers. #[derive(Clone, Debug, Eq, PartialEq)] @@ -48,14 +52,37 @@ pub fn decode_client_inputs( "unsupported client-local event ABI version {event_version}; expected {CLIENT_EVENT_ABI_VERSION}" )); } - if kind.is_empty() { - return Err("invalid client-local event payload: event kind is empty".to_owned()); + if kind.is_empty() || kind.len() > MAX_CLIENT_EVENT_KIND_BYTES { + return Err(format!( + "invalid client-local event payload: event kind must contain 1..={MAX_CLIENT_EVENT_KIND_BYTES} bytes" + )); + } + if value + .as_ref() + .is_some_and(|value| value.len() > MAX_CLIENT_EVENT_VALUE_BYTES) + { + return Err(format!( + "invalid client-local event payload: value exceeds {MAX_CLIENT_EVENT_VALUE_BYTES} bytes" + )); + } + if key + .as_ref() + .is_some_and(|key| key.len() > MAX_CLIENT_EVENT_KEY_BYTES) + { + return Err(format!( + "invalid client-local event payload: key exceeds {MAX_CLIENT_EVENT_KEY_BYTES} bytes" + )); } if state_version != CLIENT_STATE_ABI_VERSION { return Err(format!( "unsupported client-local state ABI version {state_version}; expected {CLIENT_STATE_ABI_VERSION}" )); } + if encoded_state.len() > MAX_CLIENT_STATE_BYTES { + return Err(format!( + "invalid client-local state payload: state exceeds {MAX_CLIENT_STATE_BYTES} bytes" + )); + } Ok(( ClientEvent { kind, diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index 0b7f009..c3c14be 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -226,6 +226,81 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity( "const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-hemx-client-ready') && root.hasAttribute('data-hemx-sync-ready')", ) .await?; + let rejected_inputs = driver + .execute_async( + r#" + const done = arguments[arguments.length - 1]; + (async () => { + const { reorder_card: handler } = await import('/kanban_client.js'); + const errors = {}; + for (const [name, args] of [ + ['unknown-version', [99, 'click', null, null, null, 1, '1|2']], + ['oversized-kind', [1, 'x'.repeat(257), null, null, null, 1, '1|2']], + ['oversized-value', [1, 'click', 'x'.repeat(64 * 1024 + 1), null, null, 1, '1|2']], + ['oversized-key', [1, 'keydown', null, null, 'x'.repeat(1025), 1, '1|2']], + ['unknown-state-version', [1, 'click', null, null, null, 99, '1|2']], + ['oversized-state', [1, 'click', null, null, null, 1, 'x'.repeat(1024 * 1024 + 1)]], + ]) { + try { + await handler(...args); + errors[name] = null; + } catch (error) { + errors[name] = String(error); + } + } + done(errors); + })().catch((error) => done({ harness: String(error) })); + "#, + Vec::new(), + ) + .await? + .json() + .clone(); + assert!( + rejected_inputs + .as_object() + .expect("client rejection record") + .values() + .all(|error| error.as_str().is_some_and(|message| !message.is_empty())), + "malformed client-local input reached the handler: {rejected_inputs}" + ); // req: security/005 + let rejected_sync = driver + .execute_async( + r#" + const done = arguments[arguments.length - 1]; + (async () => { + const root = document.querySelector('[data-hemx-root]'); + const before = document.querySelector('#kanban-status').textContent; + const errors = {}; + for (const [name, detail] of [ + ['malformed', '{'], + ['unknown-version', JSON.stringify({ schemaVersion: 99, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'done' })], + ['invalid-key', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'value', value: 'done' })], + ['oversized-value', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'x'.repeat(4097) })], + ]) { + root.removeAttribute('data-hemx-sync-error'); + document.dispatchEvent(new CustomEvent('hemx:sync-patch', { detail })); + await new Promise((resolve) => setTimeout(resolve, 10)); + errors[name] = root.getAttribute('data-hemx-sync-error'); + } + done({ errors, pending: root.getAttribute('data-hemx-sync-pending'), unchanged: before === document.querySelector('#kanban-status').textContent }); + })().catch((error) => done({ harness: String(error) })); + "#, + Vec::new(), + ) + .await? + .json() + .clone(); + assert!( + rejected_sync["errors"] + .as_object() + .expect("sync rejection record") + .values() + .all(|error| error.as_str().is_some_and(|message| !message.is_empty())), + "malformed sync patch reached durable storage: {rejected_sync}" + ); + assert_eq!(rejected_sync["pending"], "0", "{rejected_sync}"); + assert_eq!(rejected_sync["unchanged"], true, "{rejected_sync}"); driver .execute( "window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', event => window.__clientErrors.push(event.detail)); Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => false }); document.querySelector('[data-hemx-client-event=drop]').dispatchEvent(new Event('drop', { bubbles: true })); return true",