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