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
335 lines
12 KiB
JavaScript
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,
|
|
};
|