fix(security): bound untrusted wire decoding
req: security/005
This commit is contained in:
@@ -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