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 {
|
function assertNoInheritedToJson(prototype: object | null, path: string): void {
|
||||||
|
const visited = new Set<object>();
|
||||||
let current = prototype;
|
let current = prototype;
|
||||||
while (current !== null) {
|
while (current !== null) {
|
||||||
|
if (visited.has(current)) invalidEnvelope(path, 'prototype cycle is not allowed');
|
||||||
|
visited.add(current);
|
||||||
if (Object.getOwnPropertyDescriptor(current, 'toJSON')) {
|
if (Object.getOwnPropertyDescriptor(current, 'toJSON')) {
|
||||||
invalidEnvelope(path, 'inherited toJSON is not allowed');
|
invalidEnvelope(path, 'inherited toJSON is not allowed');
|
||||||
}
|
}
|
||||||
@@ -165,6 +168,7 @@ function snapshotJsonValue(
|
|||||||
path: string,
|
path: string,
|
||||||
ancestors: Set<object>,
|
ancestors: Set<object>,
|
||||||
audits: CapturedNode[],
|
audits: CapturedNode[],
|
||||||
|
snapshots: Map<object, unknown>,
|
||||||
): unknown {
|
): unknown {
|
||||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
@@ -178,6 +182,7 @@ function snapshotJsonValue(
|
|||||||
|
|
||||||
const objectValue = value as object;
|
const objectValue = value as object;
|
||||||
if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data');
|
if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data');
|
||||||
|
if (snapshots.has(objectValue)) return snapshots.get(objectValue);
|
||||||
ancestors.add(objectValue);
|
ancestors.add(objectValue);
|
||||||
try {
|
try {
|
||||||
const captured = captureNode(objectValue, path, audits);
|
const captured = captureNode(objectValue, path, audits);
|
||||||
@@ -192,6 +197,7 @@ function snapshotJsonValue(
|
|||||||
const allowedKeys = new Set<string>(['length']);
|
const allowedKeys = new Set<string>(['length']);
|
||||||
const snapshot: unknown[] = [];
|
const snapshot: unknown[] = [];
|
||||||
Object.setPrototypeOf(snapshot, null);
|
Object.setPrototypeOf(snapshot, null);
|
||||||
|
snapshots.set(objectValue, snapshot);
|
||||||
for (let index = 0; index < (length as number); index++) {
|
for (let index = 0; index < (length as number); index++) {
|
||||||
const key = String(index);
|
const key = String(index);
|
||||||
const itemPath = `${path}[${index}]`;
|
const itemPath = `${path}[${index}]`;
|
||||||
@@ -204,6 +210,7 @@ function snapshotJsonValue(
|
|||||||
itemPath,
|
itemPath,
|
||||||
ancestors,
|
ancestors,
|
||||||
audits,
|
audits,
|
||||||
|
snapshots,
|
||||||
);
|
);
|
||||||
Object.defineProperty(snapshot, key, {
|
Object.defineProperty(snapshot, key, {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -225,6 +232,7 @@ function snapshotJsonValue(
|
|||||||
invalidEnvelope(path, 'expected plain object or null-prototype object');
|
invalidEnvelope(path, 'expected plain object or null-prototype object');
|
||||||
}
|
}
|
||||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||||
|
snapshots.set(objectValue, snapshot);
|
||||||
for (const { key } of captured.properties) {
|
for (const { key } of captured.properties) {
|
||||||
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||||
const nestedPath = childPath(path, key);
|
const nestedPath = childPath(path, key);
|
||||||
@@ -233,6 +241,7 @@ function snapshotJsonValue(
|
|||||||
nestedPath,
|
nestedPath,
|
||||||
ancestors,
|
ancestors,
|
||||||
audits,
|
audits,
|
||||||
|
snapshots,
|
||||||
);
|
);
|
||||||
Object.defineProperty(snapshot, key, {
|
Object.defineProperty(snapshot, key, {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -252,6 +261,7 @@ function serializeOutbound(envelope: OutboundEnvelope): string {
|
|||||||
return invalidEnvelope('$', 'expected object');
|
return invalidEnvelope('$', 'expected object');
|
||||||
}
|
}
|
||||||
const audits: CapturedNode[] = [];
|
const audits: CapturedNode[] = [];
|
||||||
|
const snapshots = new Map<object, unknown>();
|
||||||
const captured = captureNode(envelope, '$', audits);
|
const captured = captureNode(envelope, '$', audits);
|
||||||
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
|
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
|
||||||
return invalidEnvelope('$', 'expected plain object or null-prototype object');
|
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) {
|
if (typeof rpc !== 'string' || rpc.length === 0) {
|
||||||
invalidEnvelope('$.rpc', 'expected non-empty string');
|
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>([
|
const snapshotValues = new Map<string, unknown>([
|
||||||
['app', app],
|
['app', app],
|
||||||
['route', route],
|
['route', route],
|
||||||
['rpc', rpc],
|
['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) {
|
for (const { key } of captured.properties) {
|
||||||
Object.defineProperty(snapshot, key as string, {
|
Object.defineProperty(snapshot, key as string, {
|
||||||
configurable: true,
|
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 () => {
|
test('send rejects source graphs whose descriptors, own-key order, or prototype change', async () => {
|
||||||
const cases: ReadonlyArray<readonly [string, () => object, RegExp]> = [
|
const cases: ReadonlyArray<readonly [string, () => object, RegExp]> = [
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user