fix(security): bound untrusted wire decoding
req: security/005
This commit is contained in:
+29
-2
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user