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
172 lines
6.1 KiB
JavaScript
172 lines
6.1 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const test = require("node:test");
|
|
|
|
const {
|
|
PluginNetworkBroker,
|
|
MAX_NETWORK_BODY_BYTES,
|
|
MAX_NETWORK_HEADER_BYTES,
|
|
} = require("./networkBroker.cjs");
|
|
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
|
|
|
function runtimeContext() {
|
|
return {
|
|
pluginId: "com.example.network",
|
|
pluginVersion: "1.0.0",
|
|
runtimeId: "runtime-1",
|
|
runtimeKind: "browser",
|
|
manifest: { permissions: { optional: ["network"] } },
|
|
signal: new AbortController().signal,
|
|
assertActive: async () => {},
|
|
};
|
|
}
|
|
|
|
test("network broker strips cookies, bounds responses, and charges decoded bytes", async () => {
|
|
const calls = [];
|
|
const charged = [];
|
|
const broker = new PluginNetworkBroker({
|
|
fetch: async (url, options) => {
|
|
calls.push({ url: String(url), options });
|
|
return new Response("hello", {
|
|
status: 200,
|
|
headers: { "content-type": "text/plain", "set-cookie": "secret=1" },
|
|
});
|
|
},
|
|
permissionEngine: { authorize: async () => {} },
|
|
quotaManager: { chargeBytes: (...args) => charged.push(args) },
|
|
});
|
|
const response = await broker.request({
|
|
url: "https://example.com/data",
|
|
headers: { accept: "text/plain" },
|
|
}, runtimeContext());
|
|
assert.equal(calls[0].options.credentials, "omit");
|
|
assert.equal(calls[0].options.redirect, "manual");
|
|
assert.equal(response.headers["set-cookie"], undefined);
|
|
assert.equal(Buffer.from(response.body.data, "base64").toString(), "hello");
|
|
assert.deepEqual(charged, [["runtime-1", "network", 5]]);
|
|
});
|
|
|
|
test("every redirect origin is authorized and cross-origin credentials are stripped", async () => {
|
|
const calls = [];
|
|
const authorized = [];
|
|
const broker = new PluginNetworkBroker({
|
|
fetch: async (url, options) => {
|
|
calls.push({ url: String(url), options });
|
|
if (calls.length === 1) {
|
|
return new Response(null, { status: 302, headers: { location: "https://other.example/final" } });
|
|
}
|
|
return new Response("ok", { status: 200 });
|
|
},
|
|
permissionEngine: { authorize: async (_context, descriptor) => authorized.push(descriptor) },
|
|
});
|
|
await broker.request({
|
|
url: "https://first.example/start",
|
|
headers: { authorization: "Bearer host-secret", "x-request-id": "one" },
|
|
}, runtimeContext());
|
|
assert.equal(authorized[0].resources[0], "https://other.example");
|
|
assert.equal(calls[1].options.headers.authorization, undefined);
|
|
assert.equal(calls[1].options.headers["x-request-id"], "one");
|
|
});
|
|
|
|
test("HTTP redirect method semantics do not replay POST bodies on 302", async () => {
|
|
const calls = [];
|
|
const charges = [];
|
|
const broker = new PluginNetworkBroker({
|
|
fetch: async (_url, options) => {
|
|
calls.push(options);
|
|
if (calls.length === 1) return new Response(null, { status: 302, headers: { location: "/done" } });
|
|
return new Response("ok");
|
|
},
|
|
permissionEngine: { authorize: async () => {} },
|
|
quotaManager: { chargeBytes: (...args) => charges.push(args) },
|
|
});
|
|
await broker.request({
|
|
url: "https://example.com/start",
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: { encoding: "utf8", data: "{}" },
|
|
}, runtimeContext());
|
|
assert.equal(calls[1].method, "GET");
|
|
assert.equal(calls[1].body, undefined);
|
|
assert.equal(calls[1].headers["content-type"], undefined);
|
|
assert.deepEqual(charges, [
|
|
["runtime-1", "network", 2],
|
|
["runtime-1", "network", 2],
|
|
], "the request body and final response are both charged");
|
|
});
|
|
|
|
test("network redirects reject embedded credentials before authorization or fetch", async () => {
|
|
let calls = 0;
|
|
let authorizations = 0;
|
|
const broker = new PluginNetworkBroker({
|
|
fetch: async () => {
|
|
calls += 1;
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { location: "https://user:password@other.example/private" },
|
|
});
|
|
},
|
|
permissionEngine: { authorize: async () => { authorizations += 1; } },
|
|
});
|
|
await assert.rejects(
|
|
broker.request({ url: "https://first.example/start" }, runtimeContext()),
|
|
/cannot contain credentials/,
|
|
);
|
|
assert.equal(calls, 1);
|
|
assert.equal(authorizations, 0);
|
|
});
|
|
|
|
test("network request validation rejects transport headers, credentials, and non-canonical bodies", () => {
|
|
const broker = new PluginNetworkBroker({
|
|
fetch: async () => new Response(),
|
|
permissionEngine: { authorize: async () => {} },
|
|
});
|
|
assert.throws(() => broker.validate({
|
|
url: "https://example.com",
|
|
headers: { cookie: "secret=1" },
|
|
}), /forbidden/);
|
|
assert.throws(() => broker.validate({ url: "https://user:pass@example.com" }), /credentials/);
|
|
assert.throws(() => broker.validate({
|
|
url: "https://example.com",
|
|
method: "POST",
|
|
body: { encoding: "base64", data: "YQ" },
|
|
}), /not canonical/);
|
|
assert.throws(() => broker.validate({
|
|
url: "https://example.com",
|
|
headers: Object.fromEntries(Array.from({ length: 9 }, (_, index) => (
|
|
[`x-budget-${index}`, "x".repeat(MAX_NETWORK_HEADER_BYTES / 8)]
|
|
))),
|
|
}), /headers are too large/);
|
|
assert.throws(() => broker.validate({
|
|
url: "https://example.com",
|
|
headers: { "x-control": "before\0after" },
|
|
}), /header value is invalid/);
|
|
});
|
|
|
|
test("network response streaming stops above the byte cap", async () => {
|
|
const broker = new PluginNetworkBroker({
|
|
fetch: async () => new Response("small", {
|
|
headers: { "content-length": String(MAX_NETWORK_BODY_BYTES + 1) },
|
|
}),
|
|
permissionEngine: { authorize: async () => {} },
|
|
});
|
|
await assert.rejects(
|
|
broker.request({ url: "https://example.com" }, runtimeContext()),
|
|
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
|
);
|
|
});
|
|
|
|
test("network broker reports its own timeout as a stable deadline error", async () => {
|
|
const broker = new PluginNetworkBroker({
|
|
fetch: async (_url, options) => new Promise((_resolve, reject) => {
|
|
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
|
|
}),
|
|
permissionEngine: { authorize: async () => {} },
|
|
});
|
|
await assert.rejects(
|
|
broker.request({ url: "https://example.com", timeoutMs: 5 }, runtimeContext()),
|
|
(error) => error.code === RPC_ERRORS.deadlineExceeded,
|
|
);
|
|
});
|