mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
fix: harden plugin sockets, public routes, prebuilt binaries and login fallback
This commit is contained in:
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", {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user