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");