fix(net): validate wire frames and coalesce reconnects
This commit is contained in:
@@ -37,22 +37,112 @@ function assertSwitchTarget(value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEnvelope(path: string, reason: string): never {
|
||||
throw new Error(`invalid outbound envelope at ${path}: ${reason}`);
|
||||
}
|
||||
|
||||
function childPath(parent: string, key: string): string {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
||||
? `${parent}.${key}`
|
||||
: `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
function dataPropertyValue(owner: object, key: string, path: string): unknown {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(owner, key);
|
||||
if (!descriptor) return invalidEnvelope(path, 'missing own data property');
|
||||
if (!descriptor.enumerable) return invalidEnvelope(path, 'property must be enumerable');
|
||||
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
||||
return invalidEnvelope(path, 'accessor properties are not allowed');
|
||||
}
|
||||
return descriptor.value;
|
||||
}
|
||||
|
||||
function validateJsonValue(value: unknown, path: string, ancestors: Set<object>): void {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) invalidEnvelope(path, 'expected finite number');
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
invalidEnvelope(path, `${typeof value} is not protocol JSON data`);
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data');
|
||||
ancestors.add(objectValue);
|
||||
try {
|
||||
if (Array.isArray(objectValue)) {
|
||||
if (Object.getPrototypeOf(objectValue) !== Array.prototype) {
|
||||
invalidEnvelope(path, 'expected plain array');
|
||||
}
|
||||
const allowedKeys = new Set<string>(['length']);
|
||||
for (let index = 0; index < objectValue.length; index++) {
|
||||
const key = String(index);
|
||||
const itemPath = `${path}[${index}]`;
|
||||
allowedKeys.add(key);
|
||||
if (!Object.prototype.hasOwnProperty.call(objectValue, key)) {
|
||||
invalidEnvelope(itemPath, 'array hole is not protocol JSON data');
|
||||
}
|
||||
validateJsonValue(
|
||||
dataPropertyValue(objectValue, key, itemPath),
|
||||
itemPath,
|
||||
ancestors,
|
||||
);
|
||||
}
|
||||
for (const key of Reflect.ownKeys(objectValue)) {
|
||||
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||
if (!allowedKeys.has(key as string)) {
|
||||
invalidEnvelope(childPath(path, key as string), 'extra array property is not allowed');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(objectValue);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
invalidEnvelope(path, 'expected plain object or null-prototype object');
|
||||
}
|
||||
for (const key of Reflect.ownKeys(objectValue)) {
|
||||
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||
const propertyPath = childPath(path, key as string);
|
||||
validateJsonValue(
|
||||
dataPropertyValue(objectValue, key as string, propertyPath),
|
||||
propertyPath,
|
||||
ancestors,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(objectValue);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeOutbound(envelope: OutboundEnvelope): string {
|
||||
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
|
||||
throw new Error('invalid outbound envelope');
|
||||
return invalidEnvelope('$', 'expected object');
|
||||
}
|
||||
const value = envelope as unknown as Record<string, unknown>;
|
||||
const owns = (key: string) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
if (
|
||||
!owns('app') || value.app !== APP
|
||||
|| !owns('route') || typeof value.route !== 'string' || value.route.length === 0
|
||||
|| !owns('rpc') || typeof value.rpc !== 'string' || value.rpc.length === 0
|
||||
|| !owns('data') || value.data === undefined
|
||||
) {
|
||||
throw new Error('invalid outbound envelope');
|
||||
const prototype = Object.getPrototypeOf(envelope);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
return invalidEnvelope('$', 'expected plain object or null-prototype object');
|
||||
}
|
||||
const required = new Set(['app', 'route', 'rpc', 'data']);
|
||||
for (const key of Reflect.ownKeys(envelope)) {
|
||||
if (typeof key === 'symbol') return invalidEnvelope('$', 'top-level symbol key is not allowed');
|
||||
if (!required.has(key)) return invalidEnvelope(childPath('$', key), 'extra top-level property');
|
||||
}
|
||||
const app = dataPropertyValue(envelope, 'app', '$.app');
|
||||
const route = dataPropertyValue(envelope, 'route', '$.route');
|
||||
const rpc = dataPropertyValue(envelope, 'rpc', '$.rpc');
|
||||
const data = dataPropertyValue(envelope, 'data', '$.data');
|
||||
if (app !== APP) invalidEnvelope('$.app', `expected ${APP}`);
|
||||
if (typeof route !== 'string' || route.length === 0) {
|
||||
invalidEnvelope('$.route', 'expected non-empty string');
|
||||
}
|
||||
if (typeof rpc !== 'string' || rpc.length === 0) {
|
||||
invalidEnvelope('$.rpc', 'expected non-empty string');
|
||||
}
|
||||
validateJsonValue(data, '$.data', new Set<object>());
|
||||
const frame = JSON.stringify(envelope);
|
||||
if (typeof frame !== 'string') throw new Error('invalid outbound envelope');
|
||||
if (typeof frame !== 'string') return invalidEnvelope('$', 'not JSON serializable');
|
||||
return frame;
|
||||
}
|
||||
|
||||
@@ -69,6 +159,7 @@ export class WireClient {
|
||||
|
||||
private transport: Transport | null = null;
|
||||
private reconnectTimer: unknown = null;
|
||||
private reconnectTarget: string | null = null;
|
||||
private reconnectToken = 0;
|
||||
private generation = 0;
|
||||
private started = false;
|
||||
@@ -117,7 +208,9 @@ export class WireClient {
|
||||
|
||||
reconnectCurrent(): void {
|
||||
this.assertRunning();
|
||||
this.disconnectAndSchedule(this.policy.current());
|
||||
const server = this.policy.current();
|
||||
if (this.reconnectTimer !== null && this.reconnectTarget === server) return;
|
||||
this.disconnectAndSchedule(server);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -241,30 +334,29 @@ export class WireClient {
|
||||
this.clearReconnectTimer();
|
||||
if (this.stopped || this.policy.current() !== server) return;
|
||||
const token = this.reconnectToken;
|
||||
this.emit({ type: 'reconnecting', server });
|
||||
if (
|
||||
this.stopped
|
||||
|| token !== this.reconnectToken
|
||||
|| this.policy.current() !== server
|
||||
) return;
|
||||
let handle: unknown = null;
|
||||
handle = this.clock.setTimeout(() => {
|
||||
if (
|
||||
this.stopped
|
||||
|| token !== this.reconnectToken
|
||||
|| this.policy.current() !== server
|
||||
|| this.reconnectTarget !== server
|
||||
|| this.reconnectTimer !== handle
|
||||
) return;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectTarget = null;
|
||||
this.reconnectToken++;
|
||||
this.connect(server);
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
this.reconnectTimer = handle;
|
||||
this.reconnectTarget = server;
|
||||
this.emit({ type: 'reconnecting', server });
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
this.reconnectToken++;
|
||||
if (this.reconnectTimer !== null) this.clock.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user