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