diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index ae9aa19db..e8bc958cc 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1631,6 +1631,8 @@ export interface PluginNotify { } export interface PluginFetchInit { + /** Return redirect responses without following them, or reject them (default). */ + redirect?: "error" | "manual"; method?: string; headers?: Record; body?: string; diff --git a/plugins/homepage/locales/en.json b/plugins/homepage/locales/en.json index f7d6fe17b..78f5b6584 100644 --- a/plugins/homepage/locales/en.json +++ b/plugins/homepage/locales/en.json @@ -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." + } } } diff --git a/plugins/homepage/locales/translated/zh_CN.json b/plugins/homepage/locales/translated/zh_CN.json index 2009051b2..3dfacbcda 100644 --- a/plugins/homepage/locales/translated/zh_CN.json +++ b/plugins/homepage/locales/translated/zh_CN.json @@ -158,5 +158,15 @@ "selectHosts": "选择主机", "listLayout": "列表", "gridLayout": "网格" + }, + "settings": { + "privateEndpoints": { + "label": "允许访问的私网主机", + "description": "允许 Ping 状态和自定义 API 组件访问的私网或回环主机名/IP。用逗号或换行分隔,不包含协议、端口或路径。留空则禁止访问私网。" + }, + "privateCertificateAuthority": { + "label": "私网证书颁发机构(PEM)", + "description": "可选的可信 CA 证书,仅用于白名单中的主机。仍会验证证书;留空使用系统信任库。" + } } } diff --git a/plugins/homepage/manifest.json b/plugins/homepage/manifest.json index 8b6c5fd08..ccbc32ec3 100644 --- a/plugins/homepage/manifest.json +++ b/plugins/homepage/manifest.json @@ -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": "" + } + ] + } } } diff --git a/plugins/homepage/src/backend/outbound-routes.ts b/plugins/homepage/src/backend/outbound-routes.ts index c4bc2f555..7a2a5c048 100644 --- a/plugins/homepage/src/backend/outbound-routes.ts +++ b/plugins/homepage/src/backend/outbound-routes.ts @@ -127,6 +127,35 @@ export function registerOutboundRoutes( expires: number; } const pingCache = new Map(); + + 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(); @@ -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 => { 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); diff --git a/plugins/homepage/tests/backend/helpers.ts b/plugins/homepage/tests/backend/helpers.ts index 5aaf90e8d..9ef16edc1 100644 --- a/plugins/homepage/tests/backend/helpers.ts +++ b/plugins/homepage/tests/backend/helpers.ts @@ -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; + fetch?: (url: string, init?: PluginFetchInit) => Promise; } = {}, ): Promise { const db = await createTestDb(pluginDir); diff --git a/plugins/homepage/tests/backend/outbound-routes.test.ts b/plugins/homepage/tests/backend/outbound-routes.test.ts index 33b9ed938..53521404e 100644 --- a/plugins/homepage/tests/backend/outbound-routes.test.ts +++ b/plugins/homepage/tests/backend/outbound-routes.test.ts @@ -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( + 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(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(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"); + }); +}); diff --git a/src/backend/plugins/ctx-notify.ts b/src/backend/plugins/ctx-notify.ts index 8d8d8c32a..f40d3d8af 100644 --- a/src/backend/plugins/ctx-notify.ts +++ b/src/backend/plugins/ctx-notify.ts @@ -173,6 +173,7 @@ export function createPluginFetch(deps: Deps): PluginFetch { url, { method: init.method ?? "GET", + redirect: init.redirect, headers: init.headers, body: init.body, signal: controller.signal, diff --git a/src/backend/tests/plugins/ctx-notify.test.ts b/src/backend/tests/plugins/ctx-notify.test.ts index e6536001c..a7ea183df 100644 --- a/src/backend/tests/plugins/ctx-notify.test.ts +++ b/src/backend/tests/plugins/ctx-notify.test.ts @@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({ fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>, lastTls: null as Record | null, lastSignal: null as AbortSignal | null, + lastRedirect: undefined as RequestRedirect | undefined, })); vi.mock("../../database/repositories/factory.js", () => ({ @@ -66,6 +67,7 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({ tls: Record = {}, ) => { state.lastTls = tls; + state.lastRedirect = init.redirect; state.lastSignal = init.signal ?? null; state.fetchCalls.push({ url, allowlist }); return new Response("ok", { status: 200 }); @@ -256,6 +258,14 @@ describe("ctx.notify", () => { }); 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 () => { const ctx = contextFor([]); await expect(ctx.fetch("https://example.com")).rejects.toThrow( diff --git a/src/backend/tests/utils/safe-outbound-fetch.test.ts b/src/backend/tests/utils/safe-outbound-fetch.test.ts index ef3022c61..cd24b1db7 100644 --- a/src/backend/tests/utils/safe-outbound-fetch.test.ts +++ b/src/backend/tests/utils/safe-outbound-fetch.test.ts @@ -281,3 +281,38 @@ describe("safeOutboundFetch", () => { } }, 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((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((resolve) => server.close(() => resolve())); + } + }, + ); +}); diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts index f27a36121..72eb85a1f 100644 --- a/src/backend/utils/safe-outbound-fetch.ts +++ b/src/backend/utils/safe-outbound-fetch.ts @@ -222,7 +222,7 @@ export async function safeOutboundFetch( return (await undiciFetch(url.toString(), { ...options, dispatcher: proxy as unknown as Dispatcher, - redirect: "error", + redirect: options.redirect === "manual" ? "manual" : "error", } as never)) as unknown as Response; } @@ -240,7 +240,7 @@ export async function safeOutboundFetch( const response = await undiciFetch(url.toString(), { ...options, dispatcher, - redirect: "error", + redirect: options.redirect === "manual" ? "manual" : "error", }); // close() waits for the body, which the caller reads after we return. dispatcher.close().catch(() => {});