[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
334
electron/plugins/networkBroker.cjs
Normal file
334
electron/plugins/networkBroker.cjs
Normal file
@@ -0,0 +1,334 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user