mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: allow approved private notification hosts (#1330)
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
|
||||
|
||||
/**
|
||||
* Outbound HTTP for automation steps and notification channels.
|
||||
*
|
||||
* safeOutboundFetch refuses private and loopback addresses, which is the right
|
||||
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
|
||||
* on a LAN that many installs actually use. Rather than weaken the guard
|
||||
* globally, a destination can opt in explicitly; everything else about the
|
||||
* guard (scheme, embedded credentials, no redirects) still applies.
|
||||
* on a LAN that many installs actually use. Private delivery therefore needs
|
||||
* both a channel opt-in and an exact host in the administrator allowlist;
|
||||
* scheme validation, DNS pinning and redirect refusal remain in force.
|
||||
*/
|
||||
export interface AutomationFetchOptions {
|
||||
method?: string;
|
||||
@@ -52,8 +53,8 @@ export async function automationFetch(
|
||||
}
|
||||
|
||||
/**
|
||||
* The opt-in path. Keeps the parts of the guard that are always right and
|
||||
* drops only the address blocklist.
|
||||
* The opt-in path still goes through the guarded resolver. Only an exact host
|
||||
* authorized by an administrator may resolve to a private address.
|
||||
*/
|
||||
async function privateNetworkFetch(
|
||||
rawUrl: string,
|
||||
@@ -73,5 +74,6 @@ async function privateNetworkFetch(
|
||||
throw new Error("URLs with embedded credentials are not allowed");
|
||||
}
|
||||
|
||||
return fetch(rawUrl, { ...init, redirect: "error" });
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
return safeOutboundFetch(rawUrl, init, allowlist);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from "../../utils/audit-forwarder.js";
|
||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
||||
import {
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
parseNotificationAllowlist,
|
||||
} from "../../utils/notification-egress.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
@@ -1078,6 +1082,90 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/notification-private-endpoints",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
if (!(await getAdminActor(userId))) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
res.json({ hosts: parseNotificationAllowlist(raw) });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get notification endpoint allowlist", err);
|
||||
res.status(500).json({ error: "Failed to get the allowlist" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/notification-private-endpoints",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const { hosts } = req.body;
|
||||
if (!Array.isArray(hosts)) {
|
||||
return res.status(400).json({ error: "hosts must be an array" });
|
||||
}
|
||||
if (hosts.length > 50) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "At most 50 hosts are allowed" });
|
||||
}
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const entry of hosts) {
|
||||
if (typeof entry !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Each host must be a string" });
|
||||
}
|
||||
const host = entry.trim().toLowerCase();
|
||||
if (!host) continue;
|
||||
if (!/^[a-z0-9._:-]+$/.test(host)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${entry} is not a valid hostname` });
|
||||
}
|
||||
if (!cleaned.includes(host)) cleaned.push(host);
|
||||
}
|
||||
|
||||
await createCurrentSettingsRepository().set(
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
JSON.stringify(cleaned),
|
||||
);
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_notification_private_endpoints",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ hosts: cleaned }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json({ hosts: cleaned });
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to update notification endpoint allowlist",
|
||||
err,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update the allowlist" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseNotificationAllowlist } from "../../utils/notification-egress.js";
|
||||
|
||||
describe("parseNotificationAllowlist", () => {
|
||||
it("defaults to an empty list", () => {
|
||||
expect(parseNotificationAllowlist(null)).toEqual([]);
|
||||
expect(parseNotificationAllowlist("invalid")).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes configured hosts", () => {
|
||||
expect(
|
||||
parseNotificationAllowlist(
|
||||
JSON.stringify([" NTFY.Internal ", "192.168.1.20", 42, ""]),
|
||||
),
|
||||
).toEqual(["ntfy.internal", "192.168.1.20"]);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ function runHook(
|
||||
addresses: LookupAddress[] | string | undefined,
|
||||
error: NodeJS.ErrnoException | null = null,
|
||||
lookupOptions: LookupOptions = { all: true },
|
||||
allowPrivate = false,
|
||||
) {
|
||||
const fakeLookup = vi.fn(
|
||||
(
|
||||
@@ -66,7 +67,7 @@ function runHook(
|
||||
},
|
||||
);
|
||||
|
||||
const hook = createDnsLookupHook(fakeLookup);
|
||||
const hook = createDnsLookupHook(fakeLookup, allowPrivate);
|
||||
const callback = vi.fn();
|
||||
|
||||
hook("example.invalid", lookupOptions, callback);
|
||||
@@ -92,6 +93,21 @@ const publicAddresses: LookupAddress[] = [
|
||||
];
|
||||
|
||||
describe("createDnsLookupHook", () => {
|
||||
it("permits private results only for an explicitly authorized host", () => {
|
||||
const { callback } = runHook(
|
||||
[{ address: "192.168.1.20", family: 4 }],
|
||||
null,
|
||||
{ all: true },
|
||||
true,
|
||||
);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
null,
|
||||
[{ address: "192.168.1.20", family: 4 }],
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a public IPv4 address through", () => {
|
||||
const { callback } = runHook([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||
import { statsLogger } from "./logger.js";
|
||||
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
|
||||
|
||||
export interface DiscordConfig {
|
||||
url: string;
|
||||
@@ -14,7 +15,8 @@ async function fetchWithRetry(
|
||||
options: RequestInit,
|
||||
): Promise<void> {
|
||||
const attempt = async () => {
|
||||
const res = await safeOutboundFetch(url, options);
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
const res = await safeOutboundFetch(url, options, allowlist);
|
||||
if (!res.ok) {
|
||||
let body = "";
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
export const NOTIFICATION_PRIVATE_ALLOWLIST_KEY =
|
||||
"notification_private_endpoint_allowlist";
|
||||
|
||||
export function parseNotificationAllowlist(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNotificationPrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseNotificationAllowlist(raw);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { statsLogger } from "./logger.js";
|
||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
|
||||
|
||||
export interface AlertPayload {
|
||||
hostName: string;
|
||||
@@ -37,7 +38,8 @@ async function fetchWithRetry(
|
||||
options: RequestInit,
|
||||
): Promise<void> {
|
||||
const attempt = async () => {
|
||||
const res = await safeOutboundFetch(url, options);
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
const res = await safeOutboundFetch(url, options, allowlist);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ export function isBlockedAddress(address: string): boolean {
|
||||
// fake DNS resolver, instead of only through a real fetch()/Agent call —
|
||||
// the actual bug here lived entirely in this callback, several layers
|
||||
// below where undici's own "fetch failed" wrapping would otherwise hide it.
|
||||
export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
|
||||
export function createDnsLookupHook(
|
||||
dnsLookup: DnsLookupFn = lookup,
|
||||
allowPrivate = false,
|
||||
) {
|
||||
return function lookupHook(
|
||||
host: string,
|
||||
lookupOptions: LookupOptions,
|
||||
@@ -110,7 +113,10 @@ export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
|
||||
);
|
||||
}
|
||||
|
||||
if (addrs.some(({ address }) => isBlockedAddress(address))) {
|
||||
if (
|
||||
!allowPrivate &&
|
||||
addrs.some(({ address }) => isBlockedAddress(address))
|
||||
) {
|
||||
return callback(
|
||||
new Error("Private destinations are not allowed"),
|
||||
"",
|
||||
@@ -144,6 +150,7 @@ export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
|
||||
export async function safeOutboundFetch(
|
||||
rawUrl: string,
|
||||
options: RequestInit,
|
||||
allowedPrivateHosts: readonly string[] = [],
|
||||
): Promise<Response> {
|
||||
const url = new URL(rawUrl);
|
||||
if (
|
||||
@@ -155,13 +162,16 @@ export async function safeOutboundFetch(
|
||||
}
|
||||
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
if (isIP(hostname) && isBlockedAddress(hostname)) {
|
||||
const allowPrivate = allowedPrivateHosts.some(
|
||||
(host) => host.trim().toLowerCase() === hostname.toLowerCase(),
|
||||
);
|
||||
if (!allowPrivate && isIP(hostname) && isBlockedAddress(hostname)) {
|
||||
throw new Error("Private destinations are not allowed");
|
||||
}
|
||||
|
||||
const dispatcher = new Agent({
|
||||
connect: {
|
||||
lookup: createDnsLookupHook(lookup),
|
||||
lookup: createDnsLookupHook(lookup, allowPrivate),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user