docs: close a prototype-chain authorization bypass in the plan

server.ts looked procedures up with plain property indexing, so every
Object.prototype member resolved as truthy. A prototype member carries no
`permission`, so the permission gate was skipped entirely.

Verified: with a contract whose only procedure declares a permission and a
checkPermission that always denies, invoke("add") correctly returns
RPC_DENIED, while invoke("constructor") returns {"ok":true,"value":{"a":2}}
and the gate never runs.

Reachable over the wire as POST /__wrnexus/rpc/<service>/constructor by
anything that clears the internal-caller check - i.e. any workspace app.

Fixed at both layers: Object.hasOwn for the procedure and handler lookups,
and a character-class guard on the path segments before they are used as
lookup keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:42:49 +05:30
co-authored by Claude Opus 5
parent e01915823a
commit fcf4ed3039
@@ -1460,8 +1460,14 @@ export function implement<Procedures extends AnyProcedures>(
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 });
}