fix(security): bound untrusted wire decoding

req: security/005
This commit is contained in:
slhx agent
2026-07-13 21:31:34 +02:00
parent 2a8aa4c07f
commit 3ac9549cc3
6 changed files with 335 additions and 24 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+37 -1
View File
@@ -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) {
+119
View File
@@ -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
+72 -18
View File
@@ -406,11 +406,16 @@
}
function applyBatch(buffer, root) {
const batch = decodeBatch(buffer);
if (batch.abiVersion !== runtimeAbiVersion) {
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) {
location.reload();
@@ -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;
}
+29 -2
View File
@@ -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,
+75
View File
@@ -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",