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:
ZacharyZcR authored and GitHub committed 2026-09-27 00:11:41 -05:00
1 parent 3b9a1bacf0
commit 2824b49ba9
11 files changed
+232 -10

No files matched your search

+10
View File
@@ -167,5 +167,15 @@
"title": "Use the homepage",
"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": "选择主机",
"listLayout": "列表",
"gridLayout": "网格"
},
"settings": {
"privateEndpoints": {
"label": "允许访问的私网主机",
"description": "允许 Ping 状态和自定义 API 组件访问的私网或回环主机名/IP。用逗号或换行分隔,不包含协议、端口或路径。留空则禁止访问私网。"
},
"privateCertificateAuthority": {
"label": "私网证书颁发机构(PEM)",
"description": "可选的可信 CA 证书,仅用于白名单中的主机。仍会验证证书;留空使用系统信任库。"
}
}
}
+19 -1
View File
@@ -48,6 +48,24 @@
"actionSlots": [
{ "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;
}
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 proxyCache = new Map<string, { data: unknown; expires: number }>();
@@ -304,7 +333,9 @@ export function registerOutboundRoutes(
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()) {
return res.json({
ok: cached.ok,
@@ -318,7 +349,9 @@ export function registerOutboundRoutes(
method: "HEAD" | "GET",
): Promise<number | null> => {
const response = await ctx.fetch(url, {
...requestOptions,
method,
redirect: "manual",
timeoutMs: FETCH_TIMEOUT_MS,
});
await response.body?.cancel().catch(() => {});
@@ -332,7 +365,7 @@ export function registerOutboundRoutes(
code = await requestStatus(targetUrl, "GET");
}
const result = {
ok: code !== null && code < 400,
ok: code !== null && (code < 400 || code === 401 || code === 403),
statusCode: code,
latencyMs: Math.round(performance.now() - start),
};
@@ -340,7 +373,7 @@ export function registerOutboundRoutes(
const oldest = pingCache.keys().next().value;
if (oldest) pingCache.delete(oldest);
}
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
pingCache.set(cacheKey, { ...result, expires: Date.now() + ttl });
res.json(result);
} catch (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" });
}
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()) {
return res.json(cached.data);
}
try {
const response = await ctx.fetch(targetUrl, {
...requestOptions,
timeoutMs: FETCH_TIMEOUT_MS,
});
if (!response.ok)
@@ -412,7 +448,7 @@ export function registerOutboundRoutes(
const oldest = proxyCache.keys().next().value;
if (oldest) proxyCache.delete(oldest);
}
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
proxyCache.set(cacheKey, { data, expires: Date.now() + ttl });
res.json(data);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
+2 -1
View File
@@ -1,4 +1,5 @@
import http from "node:http";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
@@ -36,7 +37,7 @@ export async function startServer(
options: {
permissions?: string[];
users?: string[];
fetch?: (url: string, init?: { method?: string }) => Promise<Response>;
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
} = {},
): Promise<TestServer> {
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";
let server: TestServer;
@@ -94,3 +95,101 @@ describe("outbound routes go through ctx.fetch", () => {
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");
});
});