fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

This commit is contained in:
LukeGus committed 2026-10-05 19:50:19 -05:00
1 parent efb4259438
commit 5e54d95e71
16 files changed
+318 -23

No files matched your search

@@ -156,6 +156,7 @@ const baseHost = {
ip: "10.0.0.7",
port: 22,
username: "sshuser",
authType: "password",
password: "ssh-secret",
jumpHosts: JSON.stringify([{ hostId: 3 }]),
};
@@ -285,6 +286,26 @@ describe("ctx.credentials.resolveHostProtocol", () => {
});
});
it("does not fall back to an old password on a key host", async () => {
grants.set("demo", ["credentials:read"]);
state.hosts.set(7, { ...baseHost, authType: "key" });
state.logins.set("7:spice", {
protocol: "spice",
authType: "direct",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
});
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth.password).toBe("");
});
it("uses the stored credential in credential mode", async () => {
grants.set("demo", ["credentials:read"]);
state.logins.set("7:spice", {
+19 -1
View File
@@ -113,7 +113,8 @@ describe("ctx.process.ensureBinary", () => {
const contents = "#!/bin/sh\necho hi\n";
it("uses a prebuilt copy whose checksum matches, without downloading", async () => {
const prebuilt = path.join(dir, "prebuilt-tool");
await fs.mkdir(path.join(dir, "prebuilt"), { recursive: true });
const prebuilt = path.join(dir, "prebuilt", "tool");
await fs.writeFile(prebuilt, contents);
const fetch = vi.fn();
const { process: proc } = create({ fetch: fetch as never });
@@ -129,6 +130,23 @@ describe("ctx.process.ensureBinary", () => {
expect(fetch).not.toHaveBeenCalled();
});
it("never hashes a prebuilt path that is not the binary itself", async () => {
const other = path.join(dir, "secrets.db");
await fs.writeFile(other, contents);
await fs.mkdir(path.join(dir, "tool-dir", "tool"), { recursive: true });
const fetch = vi.fn(async () => new Response(contents));
const { process: proc } = create({ fetch: fetch as never });
const result = await proc.ensureBinary({
name: "tool",
version: "1",
url: "https://example.test/tool",
sha256: sha256(contents),
prebuilt: [other, "relative/tool", path.join(dir, "tool-dir", "tool")],
});
expect(result).toBe(path.join(dir, "bin", "tool"));
expect(fetch).toHaveBeenCalledOnce();
});
it("downloads once, verifies it and reuses the copy", async () => {
const fetch = vi.fn(async () => new Response(contents));
const { process: proc } = create({ fetch: fetch as never });
@@ -33,7 +33,19 @@ vi.mock("../../plugins/permissions.js", () => ({
invalidatePluginPermissionCache: vi.fn(),
}));
const TEST_PORT = 39102;
// A fixed port can land in a range Windows reserves for Hyper-V or Docker.
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
});
}
const TEST_PORT = await freePort();
function isListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
+42
View File
@@ -234,6 +234,48 @@ describe("ctx.http authentication", () => {
expect(response.status).toBe(401);
});
it("only opens the methods the public route was registered for", async () => {
const router = http.createPluginRouter({
manifest: manifest(),
options: { public: ["/webhook/:token"] },
reportError: () => {},
});
router.post("/webhook/:token", (_req, res) => res.json({ ok: true }));
router.delete("/webhook/:token", (_req, res) => res.json({ ok: true }));
const base = await startServer();
const url = `${base}/plugin-api/sample-plugin/webhook/abc`;
expect((await fetch(url, { method: "POST" })).status).toBe(200);
expect((await fetch(url)).status).toBe(401);
expect((await fetch(url, { method: "PUT" })).status).toBe(401);
});
it("does not open a literal route beside a public parameter", async () => {
const router = http.createPluginRouter({
manifest: manifest(),
options: { public: ["/webhook/:token"] },
reportError: () => {},
});
router.get("/webhook/config", (_req, res) => res.json({ secret: true }));
router.post("/webhook/config", (_req, res) => res.json({ secret: true }));
router.get("/webhook/:token", (_req, res) => res.json({ ok: true }));
router.post("/webhook/:token", (_req, res) => res.json({ ok: true }));
const base = await startServer();
const root = `${base}/plugin-api/sample-plugin/webhook`;
expect((await fetch(`${root}/config`)).status).toBe(401);
expect((await fetch(`${root}/config`, { method: "POST" })).status).toBe(
401,
);
expect((await fetch(`${root}/abc`)).status).toBe(200);
expect((await fetch(`${root}/abc`, { method: "HEAD" })).status).toBe(200);
expect((await fetch(`${root}/config`, { headers: authed })).status).toBe(
200,
);
});
it("opens everything below a trailing /* and nothing beside it", async () => {
const router = http.createPluginRouter({
manifest: manifest(),
+13 -1
View File
@@ -34,7 +34,19 @@ vi.mock("../../plugins/permissions.js", () => ({
invalidatePluginPermissionCache: vi.fn(),
}));
const CONSOLE_PORT = 39109;
// A fixed port can land in a range Windows reserves for Hyper-V or Docker.
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
});
}
const CONSOLE_PORT = await freePort();
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
+69
View File
@@ -91,6 +91,20 @@ function connect(
});
}
function connectWithHeaders(
url: string,
headers: Record<string, string>,
): Promise<{ outcome: "open"; socket: WebSocket } | { outcome: number }> {
return new Promise((resolve) => {
const socket = new WebSocket(url, { headers });
socket.once("open", () => resolve({ outcome: "open", socket }));
socket.once("unexpected-response", (_req, res) =>
resolve({ outcome: res.statusCode ?? 0 }),
);
socket.once("error", () => resolve({ outcome: 0 }));
});
}
function authProtocols(token: string): string[] {
return [`termix.jwt.${token}`];
}
@@ -159,6 +173,61 @@ describe("ctx.ws routing", () => {
});
describe("ctx.ws authentication", () => {
it("ignores the jwt cookie on an upgrade from another site", async () => {
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
const base = await startServer();
const result = await connectWithHeaders(
`${base}/plugin-ws/sample-plugin/socket`,
{ Cookie: `jwt=${state.validToken}`, Origin: "https://evil.example" },
);
expect(result.outcome).toBe(401);
});
it("accepts the jwt cookie from this server's own page", async () => {
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
const base = await startServer();
const result = await connectWithHeaders(
`${base}/plugin-ws/sample-plugin/socket`,
{
Cookie: `jwt=${state.validToken}`,
Origin: base.replace(/^ws:/, "http:"),
},
);
expect(result.outcome).toBe("open");
if (result.outcome === "open") result.socket.close();
});
it("hides cross-site cookies from a public socket's handler", async () => {
const cookies: Array<string | undefined> = [];
const users: string[] = [];
ws.registerPluginWsRoute(
"sample-plugin",
"/public",
({ request, userId, socket }) => {
cookies.push(request.headers.cookie);
users.push(userId);
(socket as WebSocket).close();
},
DECLARED,
{ public: true, optionalAuth: true },
);
const base = await startServer();
const result = await connectWithHeaders(
`${base}/plugin-ws/sample-plugin/public`,
{ Cookie: `jwt=${state.validToken}`, Origin: "https://evil.example" },
);
expect(result.outcome).toBe("open");
await vi.waitFor(() => expect(users).toHaveLength(1));
expect(users).toEqual([""]);
expect(cookies).toEqual([undefined]);
});
it("rejects an upgrade with no token", async () => {
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);