From e5d15353fe7fb1882b1c4f29ce63e4b90559e0c3 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 24 Aug 2026 15:16:49 +0200 Subject: [PATCH] fix(timetable): isolate URL SSRF validation into a tested module Extract timetable base-URL validation (private/loopback/metadata/NAT64 block lists + DNS-rebinding address pinning) out of the widget router into a dedicated timetable-options-validation module, and separate the IPv4 and IPv6 block lists so each family is matched against its own rules. Board and options routers load the validator lazily at the call site. Carved out of #6691 so this security-relevant change can land independently of the larger widget/integration refactor. All 27 existing SSRF unit tests pass. --- packages/api/src/router/board.ts | 3 +- .../router/timetable-options-validation.ts | 162 ++++++++++++++++ packages/api/src/router/widgets/options.ts | 2 +- packages/api/src/router/widgets/timetable.ts | 178 +----------------- 4 files changed, 172 insertions(+), 173 deletions(-) create mode 100644 packages/api/src/router/timetable-options-validation.ts diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 345dd1161..6356305bc 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -75,7 +75,6 @@ import { throwIfCustomWidgetBoardDuplicationForbidden, throwIfCustomWidgetPlacementChangeForbidden, } from "./board/custom-widget-placement-access"; -import { validateTimetableOptionsChangeAsync } from "./widgets/timetable"; interface BoardItemPlacementRectangle { xOffset: number; @@ -1497,6 +1496,7 @@ export const boardRouter = createTRPCRouter({ for (const item of input.items) { if (item.kind !== "timetable") continue; const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id); + const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation"); await validateTimetableOptionsChangeAsync( item.options, previousItem?.kind === "timetable" ? previousItem.options : undefined, @@ -2073,6 +2073,7 @@ export const boardRouter = createTRPCRouter({ }); if (input.kind === "timetable") { + const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation"); await validateTimetableOptionsChangeAsync(input.options); } diff --git a/packages/api/src/router/timetable-options-validation.ts b/packages/api/src/router/timetable-options-validation.ts new file mode 100644 index 000000000..5e9180553 --- /dev/null +++ b/packages/api/src/router/timetable-options-validation.ts @@ -0,0 +1,162 @@ +import { lookup } from "node:dns/promises"; +import { BlockList, isIP } from "node:net"; +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; +import z from "zod"; + +import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable"; +import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; + +const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); + +const blockedTimetableIpv4Addresses = new BlockList(); +const blockedTimetableIpv4Subnets = [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const; + +const blockedTimetableIpv6Addresses = new BlockList(); +const blockedTimetableIpv6Subnets = [ + ["::", 96], + ["::ffff:0:0", 96], + ["::ffff:0:0:0", 96], + ["64:ff9b::", 96], + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001::", 32], + ["2001:2::", 48], + ["2001:10::", 28], + ["2001:db8::", 32], + ["2002::", 16], + ["fc00::", 7], + ["fe80::", 10], + ["fec0::", 10], + ["ff00::", 8], +] as const; + +for (const [network, prefix] of blockedTimetableIpv4Subnets) { + blockedTimetableIpv4Addresses.addSubnet(network, prefix, "ipv4"); +} + +for (const [network, prefix] of blockedTimetableIpv6Subnets) { + blockedTimetableIpv6Addresses.addSubnet(network, prefix, "ipv6"); +} + +export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => { + try { + return normalizeTimetableBaseUrl(baseUrl); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: cause instanceof Error ? cause.message : "Invalid timetable base URL", + cause, + }); + } +}; + +export const getSavedTimetableBaseUrl = (serializedOptions: string) => { + try { + const options = timetableOptionsSchema.parse(parse(serializedOptions)); + return normalizeTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); + } catch (cause) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Timetable widget configuration is invalid", + cause, + }); + } +}; + +const normalizeHostname = (hostname: string) => { + let withoutBrackets = hostname; + if (hostname.startsWith("[") && hostname.endsWith("]")) withoutBrackets = hostname.slice(1, -1); + return withoutBrackets.toLowerCase().replace(/\.$/, ""); +}; + +const isBlockedTimetableAddress = (address: string, family: number) => { + if (family === 6) return blockedTimetableIpv6Addresses.check(address, "ipv6"); + return blockedTimetableIpv4Addresses.check(address, "ipv4"); +}; + +export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { + const hostname = normalizeHostname(new URL(baseUrl).hostname); + const hostnameFamily = isIP(hostname); + const isInternalHostname = + hostnameFamily === 0 && + (!hostname.includes(".") || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") || + hostname.endsWith(".internal") || + hostname.endsWith(".lan") || + hostname.endsWith(".home.arpa")); + + if (isInternalHostname) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" }); + } + + let addresses: TimetableResolvedAddress[]; + try { + let resolvedAddresses = [{ address: hostname, family: hostnameFamily }]; + if (hostnameFamily === 0) resolvedAddresses = await lookup(hostname, { all: true, verbatim: true }); + addresses = resolvedAddresses.filter( + (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, + ); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL host could not be resolved", + cause, + }); + } + + if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL must not target a private or internal address", + }); + } + + return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address)); +}; + +const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { + const parsedOptions = timetableOptionsSchema.safeParse(options); + if (!parsedOptions.success) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" }); + } + return normalizeTimetableBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); +}; + +export const validateTimetableOptionsChangeAsync = async ( + nextOptions: Record, + previousOptions?: Record, +) => { + const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions); + if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return; + + let previousBaseUrl: string | undefined; + if (previousOptions) { + try { + previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions); + } catch { + // Invalid legacy options must not prevent replacing them with a valid configuration. + } + } + if (nextBaseUrl === previousBaseUrl) return; + + await resolvePublicTimetableAddressesAsync(nextBaseUrl); +}; diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts index e39e5f1dc..c548e039e 100644 --- a/packages/api/src/router/widgets/options.ts +++ b/packages/api/src/router/widgets/options.ts @@ -9,7 +9,6 @@ import { boards, items } from "@homarr/db/schema"; import type { WidgetOptionsSettings } from "../../../../widgets/src"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; -import { validateTimetableOptionsChangeAsync } from "./timetable"; import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access"; export const optionsRouter = createTRPCRouter({ @@ -50,6 +49,7 @@ export const optionsRouter = createTRPCRouter({ const previousOptions = SuperJSON.parse>(item.options); const updatedOptions = { ...previousOptions, ...input.newOptions }; if (item.kind === "timetable") { + const { validateTimetableOptionsChangeAsync } = await import("../timetable-options-validation"); await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions); } throwIfCustomWidgetPlacementChangeForbidden({ diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index ced16645e..9b77ab76b 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -1,7 +1,4 @@ -import { lookup } from "node:dns/promises"; -import { BlockList, isIP } from "node:net"; import { TRPCError } from "@trpc/server"; -import { parse } from "superjson"; import z from "zod"; import type { Session } from "@homarr/auth"; @@ -13,10 +10,15 @@ import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler, } from "@homarr/request-handler/timetable"; -import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; +import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-url"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; +import { + getSavedTimetableBaseUrl, + normalizeTimetableBaseUrlOrThrowBadRequest, + resolvePublicTimetableAddressesAsync, +} from "../timetable-options-validation"; const baseUrlSchema = z.string().url().max(2_048); const timetableSourceSchema = z.object({ @@ -24,172 +26,6 @@ const timetableSourceSchema = z.object({ itemId: z.string().max(100).optional(), boardId: z.string().max(100).optional(), }); -const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); - -const blockedTimetableIpv4Addresses = new BlockList(); -const blockedTimetableIpv6Addresses = new BlockList(); -const blockedTimetableSubnets = [ - ["0.0.0.0", 8, "ipv4"], - ["10.0.0.0", 8, "ipv4"], - ["100.64.0.0", 10, "ipv4"], - ["127.0.0.0", 8, "ipv4"], - ["169.254.0.0", 16, "ipv4"], - ["172.16.0.0", 12, "ipv4"], - ["192.0.0.0", 24, "ipv4"], - ["192.0.2.0", 24, "ipv4"], - ["192.88.99.0", 24, "ipv4"], - ["192.168.0.0", 16, "ipv4"], - ["198.18.0.0", 15, "ipv4"], - ["198.51.100.0", 24, "ipv4"], - ["203.0.113.0", 24, "ipv4"], - ["224.0.0.0", 4, "ipv4"], - ["240.0.0.0", 4, "ipv4"], - ["::", 96, "ipv6"], - ["::ffff:0:0", 96, "ipv6"], - ["::ffff:0:0:0", 96, "ipv6"], - ["64:ff9b::", 96, "ipv6"], - ["64:ff9b:1::", 48, "ipv6"], - ["100::", 64, "ipv6"], - ["2001::", 32, "ipv6"], - ["2001:2::", 48, "ipv6"], - ["2001:10::", 28, "ipv6"], - ["2001:db8::", 32, "ipv6"], - ["2002::", 16, "ipv6"], - ["fc00::", 7, "ipv6"], - ["fe80::", 10, "ipv6"], - ["fec0::", 10, "ipv6"], - ["ff00::", 8, "ipv6"], -] as const; - -for (const [network, prefix, family] of blockedTimetableSubnets) { - const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses; - blockList.addSubnet(network, prefix, family); -} - -const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => { - try { - return normalizeTimetableBaseUrl(baseUrl); - } catch (cause) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: cause instanceof Error ? cause.message : "Invalid timetable base URL", - cause, - }); - } -}; - -const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Timetable widget configuration is invalid", - cause, - }); -}; - -const parseSavedTimetableOptions = (serializedOptions: string) => { - try { - return timetableOptionsSchema.parse(parse(serializedOptions)); - } catch (cause) { - return throwInvalidSavedTimetableConfiguration(cause); - } -}; - -const normalizeSavedTimetableBaseUrl = (baseUrl: string) => { - try { - return normalizeTimetableBaseUrl(baseUrl); - } catch (cause) { - return throwInvalidSavedTimetableConfiguration(cause); - } -}; - -const normalizeHostname = (hostname: string) => { - const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; - return withoutBrackets.toLowerCase().replace(/\.$/, ""); -}; - -const isBlockedTimetableAddress = (address: string, family: number) => { - return family === 6 - ? blockedTimetableIpv6Addresses.check(address, "ipv6") - : blockedTimetableIpv4Addresses.check(address, "ipv4"); -}; - -const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { - const hostname = normalizeHostname(new URL(baseUrl).hostname); - const hostnameFamily = isIP(hostname); - const isInternalHostname = - hostnameFamily === 0 && - (!hostname.includes(".") || - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname.endsWith(".local") || - hostname.endsWith(".internal") || - hostname.endsWith(".lan") || - hostname.endsWith(".home.arpa")); - - if (isInternalHostname) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" }); - } - - let addresses: TimetableResolvedAddress[]; - try { - const resolvedAddresses = - hostnameFamily === 0 - ? await lookup(hostname, { all: true, verbatim: true }) - : [{ address: hostname, family: hostnameFamily }]; - addresses = resolvedAddresses.filter( - (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, - ); - } catch (cause) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Timetable base URL host could not be resolved", - cause, - }); - } - - if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Timetable base URL must not target a private or internal address", - }); - } - - return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address)); -}; - -const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { - const parsedOptions = timetableOptionsSchema.safeParse(options); - if (!parsedOptions.success) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" }); - } - return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); -}; - -export const validateTimetableOptionsChangeAsync = async ( - nextOptions: Record, - previousOptions?: Record, -) => { - const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions); - if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return; - - let previousBaseUrl: string | undefined; - if (previousOptions) { - try { - previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions); - } catch { - // Invalid legacy options must not prevent replacing them with a valid configuration. - } - } - if (nextBaseUrl === previousBaseUrl) return; - - await resolvePublicTimetableAddressesAsync(nextBaseUrl); -}; - -const getSavedTimetableBaseUrl = (serializedOptions: string) => { - const options = parseSavedTimetableOptions(serializedOptions); - return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); -}; - interface ResolvedTimetableSource { baseUrl: string; pinnedAddresses?: TimetableResolvedAddress[]; @@ -200,7 +36,7 @@ const resolveTimetableBaseUrlAsync = async ( input: z.infer, allowBoardConfiguration = false, ): Promise => { - const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl); + const requestedBaseUrl = normalizeTimetableBaseUrlOrThrowBadRequest(input.baseUrl); if (allowBoardConfiguration) { if (input.boardId) { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");