mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15:24 +00:00
fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability * test(css): exercise the configured PostCSS plugin chain
This commit is contained in:
1 parent
3b9a1bacf0
commit
2824b49ba9
11 files changed
+232
-10
No files matched your search
@@ -1631,6 +1631,8 @@ export interface PluginNotify {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginFetchInit {
|
export interface PluginFetchInit {
|
||||||
|
/** Return redirect responses without following them, or reject them (default). */
|
||||||
|
redirect?: "error" | "manual";
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
|||||||
@@ -167,5 +167,15 @@
|
|||||||
"title": "Use the homepage",
|
"title": "Use the homepage",
|
||||||
"description": "View and edit the homepage canvas and dashboard service links."
|
"description": "View and edit the homepage canvas and dashboard service links."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"privateEndpoints": {
|
||||||
|
"label": "Allowed private hosts",
|
||||||
|
"description": "Exact private or loopback hostnames/IPs for Ping Status and Custom API widgets. Separate entries with commas or newlines; omit schemes, ports and paths. Empty blocks private destinations."
|
||||||
|
},
|
||||||
|
"privateCertificateAuthority": {
|
||||||
|
"label": "Private certificate authority (PEM)",
|
||||||
|
"description": "Optional trusted CA bundle for allowlisted hosts. Certificate verification remains enabled. Leave empty to use the system trust store."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,5 +158,15 @@
|
|||||||
"selectHosts": "选择主机",
|
"selectHosts": "选择主机",
|
||||||
"listLayout": "列表",
|
"listLayout": "列表",
|
||||||
"gridLayout": "网格"
|
"gridLayout": "网格"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"privateEndpoints": {
|
||||||
|
"label": "允许访问的私网主机",
|
||||||
|
"description": "允许 Ping 状态和自定义 API 组件访问的私网或回环主机名/IP。用逗号或换行分隔,不包含协议、端口或路径。留空则禁止访问私网。"
|
||||||
|
},
|
||||||
|
"privateCertificateAuthority": {
|
||||||
|
"label": "私网证书颁发机构(PEM)",
|
||||||
|
"description": "可选的可信 CA 证书,仅用于白名单中的主机。仍会验证证书;留空使用系统信任库。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,24 @@
|
|||||||
"actionSlots": [
|
"actionSlots": [
|
||||||
{ "id": "dashboard.secondaryView", "accepts": ["component"] }
|
{ "id": "dashboard.secondaryView", "accepts": ["component"] }
|
||||||
],
|
],
|
||||||
"syncEntities": ["dashboardServiceLinks", "homepageItems"]
|
"syncEntities": ["dashboardServiceLinks", "homepageItems"],
|
||||||
|
"settings": {
|
||||||
|
"admin": [
|
||||||
|
{
|
||||||
|
"key": "privateEndpoints",
|
||||||
|
"type": "textarea",
|
||||||
|
"labelKey": "settings.privateEndpoints.label",
|
||||||
|
"descriptionKey": "settings.privateEndpoints.description",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "privateCertificateAuthority",
|
||||||
|
"type": "textarea",
|
||||||
|
"labelKey": "settings.privateCertificateAuthority.label",
|
||||||
|
"descriptionKey": "settings.privateCertificateAuthority.description",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,35 @@ export function registerOutboundRoutes(
|
|||||||
expires: number;
|
expires: number;
|
||||||
}
|
}
|
||||||
const pingCache = new Map<string, PingCacheEntry>();
|
const pingCache = new Map<string, PingCacheEntry>();
|
||||||
|
|
||||||
|
const privateRequestOptions = async (url: string) => {
|
||||||
|
const settings = await ctx.settings.getAll("admin");
|
||||||
|
const raw = settings.privateEndpoints;
|
||||||
|
const allowPrivateHosts =
|
||||||
|
typeof raw === "string"
|
||||||
|
? [
|
||||||
|
...new Set(
|
||||||
|
raw
|
||||||
|
.split(/[\r\n,]+/)
|
||||||
|
.map((host) => host.trim().toLowerCase())
|
||||||
|
.filter((host) => /^[a-z0-9._:-]+$/.test(host)),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const hostname = new URL(url).hostname
|
||||||
|
.replace(/^\[|\]$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const ca = settings.privateCertificateAuthority;
|
||||||
|
return {
|
||||||
|
allowPrivateHosts,
|
||||||
|
...(allowPrivateHosts.includes(hostname) &&
|
||||||
|
typeof ca === "string" &&
|
||||||
|
ca.trim()
|
||||||
|
? { tls: { ca: ca.trim() } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const PING_CACHE_SIZE = 200;
|
const PING_CACHE_SIZE = 200;
|
||||||
|
|
||||||
const proxyCache = new Map<string, { data: unknown; expires: number }>();
|
const proxyCache = new Map<string, { data: unknown; expires: number }>();
|
||||||
@@ -304,7 +333,9 @@ export function registerOutboundRoutes(
|
|||||||
return res.status(400).json({ error: "Invalid URL" });
|
return res.status(400).json({ error: "Invalid URL" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = pingCache.get(targetUrl);
|
const requestOptions = await privateRequestOptions(targetUrl);
|
||||||
|
const cacheKey = JSON.stringify([targetUrl, requestOptions]);
|
||||||
|
const cached = pingCache.get(cacheKey);
|
||||||
if (cached && cached.expires > Date.now()) {
|
if (cached && cached.expires > Date.now()) {
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: cached.ok,
|
ok: cached.ok,
|
||||||
@@ -318,7 +349,9 @@ export function registerOutboundRoutes(
|
|||||||
method: "HEAD" | "GET",
|
method: "HEAD" | "GET",
|
||||||
): Promise<number | null> => {
|
): Promise<number | null> => {
|
||||||
const response = await ctx.fetch(url, {
|
const response = await ctx.fetch(url, {
|
||||||
|
...requestOptions,
|
||||||
method,
|
method,
|
||||||
|
redirect: "manual",
|
||||||
timeoutMs: FETCH_TIMEOUT_MS,
|
timeoutMs: FETCH_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
await response.body?.cancel().catch(() => {});
|
await response.body?.cancel().catch(() => {});
|
||||||
@@ -332,7 +365,7 @@ export function registerOutboundRoutes(
|
|||||||
code = await requestStatus(targetUrl, "GET");
|
code = await requestStatus(targetUrl, "GET");
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
ok: code !== null && code < 400,
|
ok: code !== null && (code < 400 || code === 401 || code === 403),
|
||||||
statusCode: code,
|
statusCode: code,
|
||||||
latencyMs: Math.round(performance.now() - start),
|
latencyMs: Math.round(performance.now() - start),
|
||||||
};
|
};
|
||||||
@@ -340,7 +373,7 @@ export function registerOutboundRoutes(
|
|||||||
const oldest = pingCache.keys().next().value;
|
const oldest = pingCache.keys().next().value;
|
||||||
if (oldest) pingCache.delete(oldest);
|
if (oldest) pingCache.delete(oldest);
|
||||||
}
|
}
|
||||||
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
|
pingCache.set(cacheKey, { ...result, expires: Date.now() + ttl });
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.log.warn(`Ping failed for ${targetUrl}: ${String(err)}`);
|
ctx.log.warn(`Ping failed for ${targetUrl}: ${String(err)}`);
|
||||||
@@ -389,13 +422,16 @@ export function registerOutboundRoutes(
|
|||||||
return res.status(400).json({ error: "Invalid URL" });
|
return res.status(400).json({ error: "Invalid URL" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = proxyCache.get(targetUrl);
|
const requestOptions = await privateRequestOptions(targetUrl);
|
||||||
|
const cacheKey = JSON.stringify([targetUrl, requestOptions]);
|
||||||
|
const cached = proxyCache.get(cacheKey);
|
||||||
if (cached && cached.expires > Date.now()) {
|
if (cached && cached.expires > Date.now()) {
|
||||||
return res.json(cached.data);
|
return res.json(cached.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ctx.fetch(targetUrl, {
|
const response = await ctx.fetch(targetUrl, {
|
||||||
|
...requestOptions,
|
||||||
timeoutMs: FETCH_TIMEOUT_MS,
|
timeoutMs: FETCH_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
@@ -412,7 +448,7 @@ export function registerOutboundRoutes(
|
|||||||
const oldest = proxyCache.keys().next().value;
|
const oldest = proxyCache.keys().next().value;
|
||||||
if (oldest) proxyCache.delete(oldest);
|
if (oldest) proxyCache.delete(oldest);
|
||||||
}
|
}
|
||||||
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
|
proxyCache.set(cacheKey, { data, expires: Date.now() + ttl });
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
|
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import express, { type Router } from "express";
|
import express, { type Router } from "express";
|
||||||
@@ -36,7 +37,7 @@ export async function startServer(
|
|||||||
options: {
|
options: {
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
users?: string[];
|
users?: string[];
|
||||||
fetch?: (url: string, init?: { method?: string }) => Promise<Response>;
|
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<TestServer> {
|
): Promise<TestServer> {
|
||||||
const db = await createTestDb(pluginDir);
|
const db = await createTestDb(pluginDir);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { startServer, type TestServer } from "./helpers";
|
import { startServer, type TestServer } from "./helpers";
|
||||||
|
|
||||||
let server: TestServer;
|
let server: TestServer;
|
||||||
@@ -94,3 +95,101 @@ describe("outbound routes go through ctx.fetch", () => {
|
|||||||
expect((await server.request("GET", "/favicon")).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -173,6 +173,7 @@ export function createPluginFetch(deps: Deps): PluginFetch {
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
method: init.method ?? "GET",
|
method: init.method ?? "GET",
|
||||||
|
redirect: init.redirect,
|
||||||
headers: init.headers,
|
headers: init.headers,
|
||||||
body: init.body,
|
body: init.body,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({
|
|||||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||||
lastTls: null as Record<string, unknown> | null,
|
lastTls: null as Record<string, unknown> | null,
|
||||||
lastSignal: null as AbortSignal | null,
|
lastSignal: null as AbortSignal | null,
|
||||||
|
lastRedirect: undefined as RequestRedirect | undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
@@ -66,6 +67,7 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
|||||||
tls: Record<string, unknown> = {},
|
tls: Record<string, unknown> = {},
|
||||||
) => {
|
) => {
|
||||||
state.lastTls = tls;
|
state.lastTls = tls;
|
||||||
|
state.lastRedirect = init.redirect;
|
||||||
state.lastSignal = init.signal ?? null;
|
state.lastSignal = init.signal ?? null;
|
||||||
state.fetchCalls.push({ url, allowlist });
|
state.fetchCalls.push({ url, allowlist });
|
||||||
return new Response("ok", { status: 200 });
|
return new Response("ok", { status: 200 });
|
||||||
@@ -256,6 +258,14 @@ describe("ctx.notify", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ctx.fetch", () => {
|
describe("ctx.fetch", () => {
|
||||||
|
it("forwards manual redirect handling without enabling redirect following", async () => {
|
||||||
|
const ctx = contextFor(["network:outbound"]);
|
||||||
|
await ctx.fetch("https://example.com", { redirect: "manual" });
|
||||||
|
expect(state.lastRedirect).toBe("manual");
|
||||||
|
await ctx.fetch("https://example.com");
|
||||||
|
expect(state.lastRedirect).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses without network:outbound", async () => {
|
it("refuses without network:outbound", async () => {
|
||||||
const ctx = contextFor([]);
|
const ctx = contextFor([]);
|
||||||
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
|
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
|
||||||
|
|||||||
@@ -281,3 +281,38 @@ describe("safeOutboundFetch", () => {
|
|||||||
}
|
}
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("redirect handling", () => {
|
||||||
|
it.each([undefined, "error", "follow", "manual"] as const)(
|
||||||
|
"never follows redirects with mode %s",
|
||||||
|
async (redirect) => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
paths.push(req.url!);
|
||||||
|
res.writeHead(302, { Location: "/private-target" });
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(0, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
try {
|
||||||
|
const response = safeOutboundFetch(
|
||||||
|
`http://127.0.0.1:${port}/`,
|
||||||
|
{ redirect },
|
||||||
|
["127.0.0.1"],
|
||||||
|
);
|
||||||
|
if (redirect === "manual") {
|
||||||
|
const result = await response;
|
||||||
|
expect(result.status).toBe(302);
|
||||||
|
await result.body?.cancel();
|
||||||
|
} else {
|
||||||
|
await expect(response).rejects.toThrow();
|
||||||
|
}
|
||||||
|
expect(paths).toEqual(["/"]);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -222,7 +222,7 @@ export async function safeOutboundFetch(
|
|||||||
return (await undiciFetch(url.toString(), {
|
return (await undiciFetch(url.toString(), {
|
||||||
...options,
|
...options,
|
||||||
dispatcher: proxy as unknown as Dispatcher,
|
dispatcher: proxy as unknown as Dispatcher,
|
||||||
redirect: "error",
|
redirect: options.redirect === "manual" ? "manual" : "error",
|
||||||
} as never)) as unknown as Response;
|
} as never)) as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ export async function safeOutboundFetch(
|
|||||||
const response = await undiciFetch(url.toString(), {
|
const response = await undiciFetch(url.toString(), {
|
||||||
...options,
|
...options,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
redirect: "error",
|
redirect: options.redirect === "manual" ? "manual" : "error",
|
||||||
});
|
});
|
||||||
// close() waits for the body, which the caller reads after we return.
|
// close() waits for the body, which the caller reads after we return.
|
||||||
dispatcher.close().catch(() => {});
|
dispatcher.close().catch(() => {});
|
||||||
|
|||||||
Reference in new issue
Block a user