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