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:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user