Files
NetMesh/electron/plugins/networkBroker.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

335 lines
12 KiB
JavaScript

"use strict";
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
const { canonicalizeNetworkOrigin } = require("./permissionResources.cjs");
const MAX_NETWORK_BODY_BYTES = PLUGIN_RPC_MAX_RAW_BYTES;
const MAX_NETWORK_HEADERS = 64;
const MAX_NETWORK_HEADER_BYTES = 64 * 1024;
const MAX_NETWORK_REDIRECTS = 5;
const FORBIDDEN_REQUEST_HEADERS = new Set([
"connection",
"content-length",
"cookie",
"host",
"proxy-authorization",
"transfer-encoding",
]);
const SENSITIVE_REDIRECT_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
const ENTITY_REDIRECT_HEADERS = new Set(["content-encoding", "content-language", "content-type"]);
function invalidArgument(message) {
return new PluginRpcError(RPC_ERRORS.invalidArgument, message);
}
async function cancelResponseBody(response, reason) {
try { await response.body?.cancel?.(reason); }
catch {}
}
function decodeBody(body) {
if (body === undefined) return undefined;
if (!body || typeof body !== "object" || Array.isArray(body) || typeof body.data !== "string") {
throw invalidArgument("Plugin network request body is invalid");
}
let bytes;
if (body.encoding === "utf8") bytes = Buffer.from(body.data, "utf8");
else if (body.encoding === "base64") {
bytes = Buffer.from(body.data, "base64");
if (bytes.toString("base64") !== body.data) throw invalidArgument("Plugin network base64 body is not canonical");
} else throw invalidArgument("Plugin network request body encoding is unsupported");
if (bytes.byteLength > MAX_NETWORK_BODY_BYTES) {
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network request body is too large");
}
return bytes;
}
function normalizeHeaders(value) {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw invalidArgument("Plugin network headers are invalid");
}
const entries = Object.entries(value);
if (entries.length > MAX_NETWORK_HEADERS) throw invalidArgument("Plugin network request has too many headers");
const headers = {};
let headerBytes = 0;
for (const [rawName, rawValue] of entries) {
const name = rawName.toLowerCase();
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/u.test(name) || FORBIDDEN_REQUEST_HEADERS.has(name)) {
throw invalidArgument(`Plugin network request header is forbidden: ${rawName}`);
}
if (
typeof rawValue !== "string"
|| rawValue.length > 8_192
|| /[\u0000-\u0008\u000a-\u001f\u007f]/u.test(rawValue)
) {
throw invalidArgument(`Plugin network request header value is invalid: ${rawName}`);
}
headerBytes += Buffer.byteLength(name) + Buffer.byteLength(rawValue);
if (headerBytes > MAX_NETWORK_HEADER_BYTES) {
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network request headers are too large");
}
headers[name] = rawValue;
}
return headers;
}
function assertNetworkRequest(params) {
if (!params || typeof params !== "object" || Array.isArray(params)) {
throw invalidArgument("Plugin network request is invalid");
}
if (typeof params.url !== "string" || params.url.length > 8_192) {
throw invalidArgument("Plugin network URL is invalid");
}
let url;
try { url = new URL(params.url); }
catch { throw invalidArgument("Plugin network URL is invalid"); }
const origin = canonicalizeNetworkOrigin(url.origin);
if (url.username || url.password) throw invalidArgument("Plugin network URL cannot contain credentials");
const method = params.method ?? "GET";
if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(method)) {
throw invalidArgument("Plugin network method is unsupported");
}
const timeoutMs = params.timeoutMs ?? 30_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) {
throw invalidArgument("Plugin network timeout is invalid");
}
const body = decodeBody(params.body);
if (body && (method === "GET" || method === "HEAD")) {
throw invalidArgument(`Plugin network ${method} requests cannot contain a body`);
}
return {
url,
origin,
method,
headers: normalizeHeaders(params.headers),
body,
timeoutMs,
};
}
async function readBoundedResponse(response, maxBytes, signal) {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await cancelResponseBody(response);
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network response is too large");
}
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network response is too large");
}
chunks.push(Buffer.from(value));
}
} catch (error) {
try { await reader.cancel(error); } catch {}
throw error;
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, total);
}
function normalizeResponseHeaders(headers) {
const responseHeaders = {};
let responseHeaderBytes = 0;
let responseHeaderCount = 0;
for (const [name, value] of headers) {
if (name.toLowerCase() === "set-cookie") continue;
responseHeaderCount += 1;
responseHeaderBytes += Buffer.byteLength(name) + Buffer.byteLength(value);
if (responseHeaderCount > MAX_NETWORK_HEADERS || responseHeaderBytes > MAX_NETWORK_HEADER_BYTES) {
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin network response headers are too large");
}
responseHeaders[name.toLowerCase()] = value;
}
return responseHeaders;
}
class PluginNetworkBroker {
constructor(options) {
this.fetch = options.fetch ?? globalThis.fetch;
this.permissionEngine = options.permissionEngine;
this.quotaManager = options.quotaManager ?? null;
if (typeof this.fetch !== "function") throw new TypeError("Plugin network fetch implementation is required");
}
validate(params) {
const request = assertNetworkRequest(params);
const validated = {
url: request.url.href,
method: request.method,
headers: request.headers,
...(request.body === undefined ? {} : {
body: { encoding: "base64", data: request.body.toString("base64") },
}),
timeoutMs: request.timeoutMs,
};
// Preserve host-consumed credential fields. assertNetworkRequest ignores
// unknown keys; without this, registry validateParams would strip them and
// network.request lease injection would become dead code.
if (params?.credentialLease !== undefined) {
const lease = params.credentialLease;
if (
!lease
|| typeof lease !== "object"
|| Array.isArray(lease)
|| lease.kind !== "secret-lease"
|| typeof lease.id !== "string"
|| typeof lease.operationId !== "string"
|| !Number.isFinite(lease.expiresAt)
) {
throw invalidArgument("Plugin network credential lease is invalid");
}
validated.credentialLease = Object.freeze({
kind: "secret-lease",
id: lease.id,
operationId: lease.operationId,
expiresAt: lease.expiresAt,
});
}
if (params?.authorization !== undefined) {
const authorization = params.authorization;
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
throw invalidArgument("Plugin network authorization is invalid");
}
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
validated.authorization = Object.freeze({
scheme,
...(typeof authorization.username === "string"
? { username: authorization.username }
: {}),
});
}
return validated;
}
describeAuthorization(params) {
const request = assertNetworkRequest(params);
return {
permission: "network",
resources: [request.origin],
reason: `Connect to ${request.origin}`,
operationId: `network:${request.origin}`,
};
}
async request(params, context) {
const request = assertNetworkRequest(params);
const timeout = AbortSignal.timeout(request.timeoutMs);
const signal = context.signal ? AbortSignal.any([context.signal, timeout]) : timeout;
let currentUrl = request.url;
let currentOrigin = request.origin;
let currentMethod = request.method;
let currentBody = request.body;
let headers = { ...request.headers };
try {
for (let redirects = 0; redirects <= MAX_NETWORK_REDIRECTS; redirects += 1) {
await context.assertActive();
if (currentBody) {
this.quotaManager?.chargeBytes(context.runtimeId, "network", currentBody.byteLength);
}
const response = await this.fetch(currentUrl, {
method: currentMethod,
headers,
...(currentBody === undefined ? {} : { body: currentBody }),
credentials: "omit",
redirect: "manual",
signal,
});
const effectiveUrl = response.url ? new URL(response.url) : currentUrl;
if (
effectiveUrl.username
|| effectiveUrl.password
|| canonicalizeNetworkOrigin(effectiveUrl.origin) !== currentOrigin
) {
await cancelResponseBody(response);
throw new PluginRpcError(
RPC_ERRORS.permissionDenied,
"Plugin network transport followed an unauthorized redirect",
);
}
let responseHeaders;
try {
responseHeaders = normalizeResponseHeaders(response.headers);
} catch (error) {
await cancelResponseBody(response, error);
throw error;
}
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
await cancelResponseBody(response);
if (redirects === MAX_NETWORK_REDIRECTS) {
throw new PluginRpcError(RPC_ERRORS.outOfRange, "Plugin network redirect limit exceeded");
}
const nextUrl = new URL(response.headers.get("location"), currentUrl);
if (nextUrl.username || nextUrl.password) {
throw invalidArgument("Plugin network redirect URL cannot contain credentials");
}
const nextOrigin = canonicalizeNetworkOrigin(nextUrl.origin);
await this.permissionEngine.authorize(context, {
permission: "network",
resources: [nextOrigin],
reason: `Follow network redirect to ${nextOrigin}`,
operationId: `network:${nextOrigin}`,
});
if (nextOrigin !== currentOrigin) {
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => (
!SENSITIVE_REDIRECT_HEADERS.has(name)
)));
}
if (response.status === 303 || ((response.status === 301 || response.status === 302) && currentMethod === "POST")) {
currentMethod = currentMethod === "HEAD" ? "HEAD" : "GET";
currentBody = undefined;
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => (
!ENTITY_REDIRECT_HEADERS.has(name)
)));
}
currentUrl = nextUrl;
currentOrigin = nextOrigin;
continue;
}
const bytes = await readBoundedResponse(response, MAX_NETWORK_BODY_BYTES, signal);
this.quotaManager?.chargeBytes(context.runtimeId, "network", bytes.byteLength);
await context.assertActive();
return {
url: effectiveUrl.href,
status: response.status,
headers: responseHeaders,
body: { encoding: "base64", data: bytes.toString("base64") },
};
}
} catch (error) {
context.signal?.throwIfAborted();
if (timeout.aborted) {
throw new PluginRpcError(RPC_ERRORS.deadlineExceeded, "Plugin network request timed out");
}
throw error;
}
throw new PluginRpcError(RPC_ERRORS.internal, "Plugin network redirect handling failed");
}
}
module.exports = {
FORBIDDEN_REQUEST_HEADERS,
ENTITY_REDIRECT_HEADERS,
MAX_NETWORK_BODY_BYTES,
MAX_NETWORK_HEADER_BYTES,
MAX_NETWORK_HEADERS,
MAX_NETWORK_REDIRECTS,
PluginNetworkBroker,
assertNetworkRequest,
cancelResponseBody,
normalizeResponseHeaders,
readBoundedResponse,
};