diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index 46245b9a..f62afbac 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -1460,8 +1460,14 @@ export function implement( contract, async invoke(procedureName, payload, identity) { - const def = contract.procedures[procedureName as keyof Procedures]; - const handler = handlers[procedureName as keyof Procedures]; + // Object.hasOwn, not plain indexing: "constructor", "toString" and every + // other Object.prototype member otherwise resolve as truthy, and a + // prototype member carries no `permission`, so the gate below is skipped + // entirely and an unintended function runs with attacker-controlled input. + const known = + Object.hasOwn(contract.procedures, procedureName) && Object.hasOwn(handlers, procedureName); + const def = known ? contract.procedures[procedureName as keyof Procedures] : undefined; + const handler = known ? handlers[procedureName as keyof Procedures] : undefined; if (!def || !handler) { return failure(RPC_ERROR_CODES.unknown, `No procedure '${contract.name}/${procedureName}'`); } @@ -2313,8 +2319,12 @@ export async function handleRpcRequest( } const [, , , serviceName, procedureName] = url.pathname.split("/"); - const service = serviceName ? services.get(serviceName) : undefined; - if (!service || !procedureName) { + // Constrain the segment before it is used as a lookup key, so a prototype + // member can never be reached even if a future implement() regresses. + const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/; + const service = + serviceName && SAFE_SEGMENT.test(serviceName) ? services.get(serviceName) : undefined; + if (!service || !procedureName || !SAFE_SEGMENT.test(procedureName)) { return json({ ok: false, code: "RPC_UNKNOWN", message: "Unknown procedure", retryable: false }); }