mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
* fix(homepage): check allowlisted private service reachability * test(css): exercise the configured PostCSS plugin chain
196 lines
6.7 KiB
TypeScript
196 lines
6.7 KiB
TypeScript
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { startServer, type TestServer } from "./helpers";
|
|
|
|
let server: TestServer;
|
|
|
|
afterEach(async () => {
|
|
await server.close();
|
|
});
|
|
|
|
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
...init,
|
|
});
|
|
}
|
|
|
|
describe("outbound routes go through ctx.fetch", () => {
|
|
it("proxy: returns the parsed JSON body from ctx.fetch", async () => {
|
|
server = await startServer({
|
|
fetch: async () => jsonResponse({ hello: "world" }),
|
|
});
|
|
const res = await server.request(
|
|
"GET",
|
|
"/proxy?url=https://example.com/api",
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ hello: "world" });
|
|
});
|
|
|
|
it("proxy: every request to a private address is refused by the SSRF guard, never reaching ctx.fetch's caller", async () => {
|
|
// The route never gets to call the stub for a rejected URL because
|
|
// safeOutboundFetch (behind ctx.fetch) throws before any response comes
|
|
// back; simulate that by having the stub itself reject, matching what
|
|
// the real safeOutboundFetch does for a blocked host.
|
|
server = await startServer({
|
|
fetch: async () => {
|
|
throw new Error("Private destinations are not allowed");
|
|
},
|
|
});
|
|
const res = await server.request(
|
|
"GET",
|
|
"/proxy?url=http://127.0.0.1:9999/secret",
|
|
);
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("proxy: rejects a non-JSON response", async () => {
|
|
server = await startServer({
|
|
fetch: async () =>
|
|
new Response("<html></html>", {
|
|
status: 200,
|
|
headers: { "content-type": "text/html" },
|
|
}),
|
|
});
|
|
const res = await server.request("GET", "/proxy?url=https://example.com");
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("ping: reports ok for a 2xx response", async () => {
|
|
server = await startServer({
|
|
fetch: async () => new Response(null, { status: 200 }),
|
|
});
|
|
const res = await server.request("GET", "/ping?url=https://example.com");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.ok).toBe(true);
|
|
});
|
|
|
|
it("rss: parses items from the feed ctx.fetch returned", async () => {
|
|
const xml = `<?xml version="1.0"?><rss><channel>
|
|
<item><title>Hello</title><link>https://example.com/1</link></item>
|
|
</channel></rss>`;
|
|
server = await startServer({
|
|
fetch: async () =>
|
|
new Response(xml, {
|
|
status: 200,
|
|
headers: { "content-type": "application/xml" },
|
|
}),
|
|
});
|
|
const res = await server.request(
|
|
"GET",
|
|
"/rss?url=https://example.com/feed",
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(1);
|
|
expect(res.body[0].title).toBe("Hello");
|
|
});
|
|
|
|
it("rejects a request with no url", async () => {
|
|
server = await startServer();
|
|
expect((await server.request("GET", "/ping")).status).toBe(400);
|
|
expect((await server.request("GET", "/rss")).status).toBe(400);
|
|
expect((await server.request("GET", "/proxy")).status).toBe(400);
|
|
expect((await server.request("GET", "/favicon")).status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe("ping reachability policy", () => {
|
|
it.each([200, 302, 307, 401, 403, 404, 503])(
|
|
"classifies HTTP %s without following redirects",
|
|
async (status) => {
|
|
const fetch = vi.fn<PluginFetch>(
|
|
async () => new Response(null, { status }),
|
|
);
|
|
server = await startServer({ fetch });
|
|
const result = await server.request(
|
|
"GET",
|
|
"/ping?url=https://example.com",
|
|
);
|
|
expect(result.body).toMatchObject({
|
|
ok: status < 400 || status === 401 || status === 403,
|
|
statusCode: status,
|
|
});
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"https://example.com",
|
|
expect.objectContaining({ redirect: "manual", allowPrivateHosts: [] }),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("retries HEAD-not-supported as GET with the same policy", async () => {
|
|
const fetch = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(new Response(null, { status: 405 }))
|
|
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
server = await startServer({ fetch });
|
|
const result = await server.request("GET", "/ping?url=https://example.com");
|
|
expect(result.body.ok).toBe(true);
|
|
expect(fetch.mock.calls.map((call) => call[1].method)).toEqual([
|
|
"HEAD",
|
|
"GET",
|
|
]);
|
|
expect(
|
|
fetch.mock.calls.every((call) => call[1].redirect === "manual"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each(["ping", "proxy"])(
|
|
"%s uses only the admin allowlist and invalidates cached results on revocation",
|
|
async (route) => {
|
|
const fetch = vi.fn<PluginFetch>(async () =>
|
|
jsonResponse({ alive: true }),
|
|
);
|
|
server = await startServer({ fetch });
|
|
await server.mock.ctx.settings.set(
|
|
"privateEndpoints",
|
|
"SERVICE.LAN,127.0.0.1\nservice.lan\nhttps://invalid.example",
|
|
);
|
|
const path = `/${route}?url=http://service.lan`;
|
|
await server.request("GET", path);
|
|
expect(fetch).toHaveBeenLastCalledWith(
|
|
"http://service.lan",
|
|
expect.objectContaining({
|
|
allowPrivateHosts: ["service.lan", "127.0.0.1"],
|
|
}),
|
|
);
|
|
await server.request("GET", path);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
await server.mock.ctx.settings.set("privateEndpoints", "");
|
|
await server.request("GET", path);
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(fetch).toHaveBeenLastCalledWith(
|
|
"http://service.lan",
|
|
expect.objectContaining({ allowPrivateHosts: [] }),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("uses custom CA trust only for allowlisted hosts and refreshes cached results on CA change", async () => {
|
|
const fetch = vi.fn<PluginFetch>(async () => jsonResponse({ alive: true }));
|
|
server = await startServer({ fetch });
|
|
await server.mock.ctx.settings.set("privateEndpoints", "service.lan");
|
|
await server.mock.ctx.settings.set(
|
|
"privateCertificateAuthority",
|
|
"PEM ONE",
|
|
);
|
|
await server.request("GET", "/ping?url=https://service.lan");
|
|
expect(fetch).toHaveBeenLastCalledWith(
|
|
"https://service.lan",
|
|
expect.objectContaining({ tls: { ca: "PEM ONE" } }),
|
|
);
|
|
await server.mock.ctx.settings.set(
|
|
"privateCertificateAuthority",
|
|
"PEM TWO",
|
|
);
|
|
await server.request("GET", "/ping?url=https://service.lan");
|
|
expect(fetch).toHaveBeenLastCalledWith(
|
|
"https://service.lan",
|
|
expect.objectContaining({ tls: { ca: "PEM TWO" } }),
|
|
);
|
|
await server.request("GET", "/ping?url=https://example.com");
|
|
expect(fetch.mock.calls.at(-1)?.[1]).not.toHaveProperty("tls");
|
|
});
|
|
});
|