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
+74 -20
View File
@@ -406,10 +406,15 @@
}
function applyBatch(buffer, root) {
const batch = decodeBatch(buffer);
if (batch.abiVersion !== runtimeAbiVersion) {
location.reload();
return;
let batch;
try {
batch = decodeBatch(buffer);
} catch (error) {
if (error?.code === "HEMX_UNSUPPORTED_ABI") {
location.reload();
return;
}
throw error;
}
const expected = root && root.getAttribute(FINGERPRINT);
if (expected && String(batch.fingerprint) !== expected) {
@@ -901,14 +906,30 @@
}
function base64UrlBytes(encoded) {
const normalized = String(encoded).replace(/-/g, "+").replace(/_/g, "/");
if (typeof encoded !== "string" || encoded.length > Math.ceil(MAX_WIRE_BYTES * 4 / 3) + 4) {
throw new Error(`encoded hemx state exceeds ${MAX_WIRE_BYTES} bytes`);
}
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
}
const MAX_WIRE_BYTES = 1024 * 1024;
const MAX_WIRE_ITEMS = 1024;
const MAX_WIRE_FIELD_BYTES = 256 * 1024;
function boundedLength(value, maximum, field) {
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
throw new Error(`${field} length ${value} exceeds ${maximum}`);
}
return value;
}
function postcardDecoder(bytes) {
if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx state exceeds ${MAX_WIRE_BYTES} bytes`);
let offset = 0;
const need = (len) => {
boundedLength(len, bytes.length - offset, "hemx state field");
const end = offset + len;
if (end > bytes.length) throw new Error("truncated hemx state");
const slice = bytes.subarray(offset, end);
@@ -916,24 +937,31 @@
return slice;
};
const varint = () => {
let shift = 0;
let value = 0;
for (;;) {
for (let index = 0; index < 5; index += 1) {
const byte = need(1)[0];
value |= (byte & 0x7f) << shift;
if (index === 4 && byte > 0x0f) throw new Error("oversized hemx state varint");
value += (byte & 0x7f) * (2 ** (index * 7));
if ((byte & 0x80) === 0) return value >>> 0;
shift += 7;
}
throw new Error("oversized hemx state varint");
};
const bytesField = () => need(boundedLength(varint(), MAX_WIRE_FIELD_BYTES, "hemx state bytes"));
const vec = (read) => {
const length = boundedLength(varint(), MAX_WIRE_ITEMS, "hemx state vector");
const values = [];
for (let index = 0; index < length; index += 1) values.push(read());
return values;
};
const bytesField = () => need(varint());
const vec = (read) => Array.from({ length: varint() }, read);
return { varint, bytes: bytesField, vec, done: () => offset === bytes.length };
}
function decoder(buffer) {
const bytes = new Uint8Array(buffer);
if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx batch exceeds ${MAX_WIRE_BYTES} bytes`);
let offset = 0;
const need = (len) => {
boundedLength(len, bytes.length - offset, "hemx batch field");
const end = offset + len;
if (end > bytes.length) throw new Error("truncated hemx batch");
const slice = bytes.subarray(offset, end);
@@ -950,21 +978,36 @@
const hi = BigInt(u32());
return lo | (hi << 32n);
};
const str = () => new TextDecoder().decode(need(u32()));
const option = (read) => u8() === 0 ? null : read();
const resource = () => ({ kind: ["slot", "atom", "handle", "form"][u8()], id: u32() });
const str = () => new TextDecoder("utf-8", { fatal: true }).decode(
need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx string")),
);
const enumValue = (values, field) => {
const discriminant = u8();
if (discriminant >= values.length) throw new Error(`unknown ${field} ${discriminant}`);
return values[discriminant];
};
const option = (read) => {
const discriminant = u8();
if (discriminant === 0) return null;
if (discriminant === 1) return read();
throw new Error(`unknown hemx option ${discriminant}`);
};
const resource = () => ({ kind: enumValue(["slot", "atom", "handle", "form"], "hemx resource"), id: u32() });
const scope = () => {
const kind = u8();
if (kind === 0) return null;
return { kind: kind === 1 ? "key" : "field", value: str() };
if (kind === 1) return { kind: "key", value: str() };
if (kind === 2) return { kind: "field", value: str() };
throw new Error(`unknown hemx scope ${kind}`);
};
const ref = () => ({ resource: resource(), scope: scope() });
const payload = () => ({ kind: u8() === 0 ? "text" : "html", value: str() });
const payload = () => ({ kind: enumValue(["text", "html"], "hemx payload"), value: str() });
const scroll = () => {
const kind = u8();
if (kind === 0) return "preserve";
if (kind === 1) return "top";
return { kind: "element", target: ref() };
if (kind === 2) return { kind: "element", target: ref() };
throw new Error(`unknown hemx scroll behavior ${kind}`);
};
const effect = () => {
const kind = u8();
@@ -974,18 +1017,29 @@
if (kind === 3) return { kind: "remove", target: ref(), key: option(str) };
if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) };
if (kind === 5) return { kind: "focus", target: ref() };
if (kind === 6) return { kind: "navigate", url: str(), mode: ["push", "replace", "redirect"][u8()], scroll: scroll(), title: option(str) };
if (kind === 6) return { kind: "navigate", url: str(), mode: enumValue(["push", "replace", "redirect"], "hemx navigation mode"), scroll: scroll(), title: option(str) };
if (kind === 7) return { kind: "emit", name: str(), payload: str() };
throw new Error(`unknown hemx effect ${kind}`);
};
const vec = (read) => Array.from({ length: u32() }, read);
const vec = (read) => {
const length = boundedLength(u32(), MAX_WIRE_ITEMS, "hemx effect vector");
const values = [];
for (let index = 0; index < length; index += 1) values.push(read());
return values;
};
return { u8, u32, u64, vec, effect, done: () => offset === bytes.length };
}
function decodeBatch(buffer) {
const d = decoder(buffer);
if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "HEMX") throw new Error("bad hemx batch magic");
const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) };
const abiVersion = d.u32();
if (abiVersion !== runtimeAbiVersion) {
const error = new Error(`unsupported hemx batch ABI version ${abiVersion}; expected ${runtimeAbiVersion}`);
error.code = "HEMX_UNSUPPORTED_ABI";
throw error;
}
const batch = { abiVersion, fingerprint: d.u64(), ops: d.vec(d.effect) };
if (!d.done()) throw new Error("trailing hemx batch bytes");
return batch;
}