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
+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",