fix(net): stabilize shared wire graph audits
This commit is contained in:
@@ -64,8 +64,11 @@ function propertyPath(parent: string, key: string | symbol): string {
|
||||
}
|
||||
|
||||
function assertNoInheritedToJson(prototype: object | null, path: string): void {
|
||||
const visited = new Set<object>();
|
||||
let current = prototype;
|
||||
while (current !== null) {
|
||||
if (visited.has(current)) invalidEnvelope(path, 'prototype cycle is not allowed');
|
||||
visited.add(current);
|
||||
if (Object.getOwnPropertyDescriptor(current, 'toJSON')) {
|
||||
invalidEnvelope(path, 'inherited toJSON is not allowed');
|
||||
}
|
||||
@@ -165,6 +168,7 @@ function snapshotJsonValue(
|
||||
path: string,
|
||||
ancestors: Set<object>,
|
||||
audits: CapturedNode[],
|
||||
snapshots: Map<object, unknown>,
|
||||
): unknown {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
@@ -178,6 +182,7 @@ function snapshotJsonValue(
|
||||
|
||||
const objectValue = value as object;
|
||||
if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data');
|
||||
if (snapshots.has(objectValue)) return snapshots.get(objectValue);
|
||||
ancestors.add(objectValue);
|
||||
try {
|
||||
const captured = captureNode(objectValue, path, audits);
|
||||
@@ -192,6 +197,7 @@ function snapshotJsonValue(
|
||||
const allowedKeys = new Set<string>(['length']);
|
||||
const snapshot: unknown[] = [];
|
||||
Object.setPrototypeOf(snapshot, null);
|
||||
snapshots.set(objectValue, snapshot);
|
||||
for (let index = 0; index < (length as number); index++) {
|
||||
const key = String(index);
|
||||
const itemPath = `${path}[${index}]`;
|
||||
@@ -204,6 +210,7 @@ function snapshotJsonValue(
|
||||
itemPath,
|
||||
ancestors,
|
||||
audits,
|
||||
snapshots,
|
||||
);
|
||||
Object.defineProperty(snapshot, key, {
|
||||
configurable: true,
|
||||
@@ -225,6 +232,7 @@ function snapshotJsonValue(
|
||||
invalidEnvelope(path, 'expected plain object or null-prototype object');
|
||||
}
|
||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||
snapshots.set(objectValue, snapshot);
|
||||
for (const { key } of captured.properties) {
|
||||
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||
const nestedPath = childPath(path, key);
|
||||
@@ -233,6 +241,7 @@ function snapshotJsonValue(
|
||||
nestedPath,
|
||||
ancestors,
|
||||
audits,
|
||||
snapshots,
|
||||
);
|
||||
Object.defineProperty(snapshot, key, {
|
||||
configurable: true,
|
||||
@@ -252,6 +261,7 @@ function serializeOutbound(envelope: OutboundEnvelope): string {
|
||||
return invalidEnvelope('$', 'expected object');
|
||||
}
|
||||
const audits: CapturedNode[] = [];
|
||||
const snapshots = new Map<object, unknown>();
|
||||
const captured = captureNode(envelope, '$', audits);
|
||||
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
|
||||
return invalidEnvelope('$', 'expected plain object or null-prototype object');
|
||||
@@ -272,13 +282,21 @@ function serializeOutbound(envelope: OutboundEnvelope): string {
|
||||
if (typeof rpc !== 'string' || rpc.length === 0) {
|
||||
invalidEnvelope('$.rpc', 'expected non-empty string');
|
||||
}
|
||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||
snapshots.set(envelope, snapshot);
|
||||
const ancestors = new Set<object>([envelope]);
|
||||
let dataSnapshot: unknown;
|
||||
try {
|
||||
dataSnapshot = snapshotJsonValue(data, '$.data', ancestors, audits, snapshots);
|
||||
} finally {
|
||||
ancestors.delete(envelope);
|
||||
}
|
||||
const snapshotValues = new Map<string, unknown>([
|
||||
['app', app],
|
||||
['route', route],
|
||||
['rpc', rpc],
|
||||
['data', snapshotJsonValue(data, '$.data', new Set<object>(), audits)],
|
||||
['data', dataSnapshot],
|
||||
]);
|
||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||
for (const { key } of captured.properties) {
|
||||
Object.defineProperty(snapshot, key as string, {
|
||||
configurable: true,
|
||||
|
||||
@@ -325,6 +325,96 @@ test('send serializes stable descriptor snapshots without invoking Proxy get tra
|
||||
);
|
||||
});
|
||||
|
||||
test('send audits one shared source identity instead of accepting path-local stable reads', async () => {
|
||||
const { client, transports } = makeHarness();
|
||||
client.start();
|
||||
const transport = transports[0]!;
|
||||
await transport.flush();
|
||||
const target = { value: 0 };
|
||||
const reportedValues = [1, 2, 1, 2] as const;
|
||||
let descriptorReads = 0;
|
||||
const shared = new Proxy(target, {
|
||||
getOwnPropertyDescriptor(source, key) {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(source, key);
|
||||
if (key !== 'value' || !descriptor) return descriptor;
|
||||
const value = reportedValues[descriptorReads];
|
||||
descriptorReads++;
|
||||
if (value === undefined) throw new Error('shared source was audited more than twice');
|
||||
return { ...descriptor, value };
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => client.send({
|
||||
app: 'youle',
|
||||
route: 'agent',
|
||||
rpc: 'shared_unstable',
|
||||
data: { left: shared, right: shared },
|
||||
}),
|
||||
/\$\.data\.left\.value.*descriptor.*changed/i,
|
||||
);
|
||||
assert.equal(descriptorReads, 2, 'the shared source must have one capture and one verification');
|
||||
assert.deepEqual(transport.sent, []);
|
||||
});
|
||||
|
||||
test('send expands an ordinary shared DAG reference at every wire path', async () => {
|
||||
const { client, transports } = makeHarness();
|
||||
client.start();
|
||||
const transport = transports[0]!;
|
||||
await transport.flush();
|
||||
const shared = { score: 7 };
|
||||
const completed = {
|
||||
app: 'youle',
|
||||
route: 'agent',
|
||||
rpc: 'shared_dag',
|
||||
data: { left: shared, right: shared },
|
||||
} as OutboundEnvelope;
|
||||
|
||||
client.send(completed);
|
||||
|
||||
assert.equal(
|
||||
transport.sent[0],
|
||||
'{"app":"youle","route":"agent","rpc":"shared_dag","data":{"left":{"score":7},"right":{"score":7}}}',
|
||||
);
|
||||
});
|
||||
|
||||
test('send rejects a cyclic prototype identity within a finite prototype walk', async () => {
|
||||
const { client, transports } = makeHarness();
|
||||
client.start();
|
||||
const transport = transports[0]!;
|
||||
await transport.flush();
|
||||
const finiteWalkLimit = 4;
|
||||
let prototypeReads = 0;
|
||||
let getCalls = 0;
|
||||
let selfPrototype!: object;
|
||||
selfPrototype = new Proxy(Object.create(null) as object, {
|
||||
getPrototypeOf() {
|
||||
prototypeReads++;
|
||||
if (prototypeReads > finiteWalkLimit) {
|
||||
throw new Error('prototype walk exceeded the finite test bound');
|
||||
}
|
||||
return selfPrototype;
|
||||
},
|
||||
get(source, key, receiver) {
|
||||
getCalls++;
|
||||
return Reflect.get(source, key, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => client.send({
|
||||
app: 'youle',
|
||||
route: 'agent',
|
||||
rpc: 'prototype_cycle',
|
||||
data: { loop: selfPrototype },
|
||||
}),
|
||||
/\$\.data\.loop.*prototype.*cycle/i,
|
||||
);
|
||||
assert.ok(prototypeReads <= finiteWalkLimit);
|
||||
assert.equal(getCalls, 0, 'prototype validation must not invoke Proxy get');
|
||||
assert.deepEqual(transport.sent, []);
|
||||
});
|
||||
|
||||
test('send rejects source graphs whose descriptors, own-key order, or prototype change', async () => {
|
||||
const cases: ReadonlyArray<readonly [string, () => object, RegExp]> = [
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user