diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx
index 8fb3b7ccd..e53f29920 100644
--- a/apps/docs/docs/widgets/timetable/index.mdx
+++ b/apps/docs/docs/widgets/timetable/index.mdx
@@ -34,3 +34,5 @@ import streetDepartures from "./img/street-departures.png";
### Configuration
+
+For security, new or changed custom base URLs must resolve to a public IP address. Private and local hosts are rejected.
diff --git a/apps/docs/docs/widgets/timetable/index.ts b/apps/docs/docs/widgets/timetable/index.ts
index 1cefb6bcd..32b9e2bcf 100644
--- a/apps/docs/docs/widgets/timetable/index.ts
+++ b/apps/docs/docs/widgets/timetable/index.ts
@@ -11,7 +11,7 @@ export const timetableWidget: WidgetDefinition = {
{
name: "Search.ch URL",
description: "Base URL for the Search.ch timetable API.",
- values: "Any valid URL.",
+ values: "An HTTP(S) URL that resolves to a public IP address.",
defaultValue: "https://search.ch",
},
{
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index d7cbebb39..3053da760 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -70,6 +70,7 @@ import {
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
+import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
@@ -894,6 +895,15 @@ export const boardRouter = createTRPCRouter({
storedItems: dbBoard.items,
});
+ for (const item of input.items) {
+ if (item.kind !== "timetable") continue;
+ const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
+ await validateTimetableOptionsChangeAsync(
+ item.options,
+ previousItem?.kind === "timetable" ? previousItem.options : undefined,
+ );
+ }
+
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
@@ -1479,6 +1489,10 @@ export const boardRouter = createTRPCRouter({
storedItems: [],
});
+ if (input.kind === "timetable") {
+ await validateTimetableOptionsChangeAsync(input.options);
+ }
+
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true },
diff --git a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts
index 48d555167..4e3db53fc 100644
--- a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts
+++ b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts
@@ -21,7 +21,7 @@ vi.hoisted(() => {
const createHandler = (integration: { name: string }, data: T) => ({
getDataAsync: async () => {
- if (integration.name === "Offline") throw new Error("offline");
+ if (integration.name === "Offline") throw new Error("offline https://internal.example?token=secret");
return { data, timestamp: new Date() };
},
});
@@ -119,9 +119,13 @@ describe("partial integration failures", () => {
});
expect(calendarResults).toEqual(
expect.arrayContaining([
- expect.objectContaining({ integration: expect.objectContaining({ id: calendar.offlineId }), error: "offline" }),
+ expect.objectContaining({
+ integration: expect.objectContaining({ id: calendar.offlineId }),
+ error: "Integration request failed",
+ }),
]),
);
+ expect(JSON.stringify(calendarResults)).not.toContain("secret");
const mediaServer = await setupAsync("plex");
const streamResults = await mediaServerRouter
@@ -129,9 +133,14 @@ describe("partial integration failures", () => {
.getCurrentStreams({ integrationIds: mediaServer.integrationIds, showOnlyPlaying: false });
expect(streamResults).toEqual(
expect.arrayContaining([
- expect.objectContaining({ integrationId: mediaServer.offlineId, sessions: [], error: "offline" }),
+ expect.objectContaining({
+ integrationId: mediaServer.offlineId,
+ sessions: [],
+ error: "Integration request failed",
+ }),
]),
);
+ expect(JSON.stringify(streamResults)).not.toContain("secret");
});
test("media request responses expose failed integrations without dropping successful data", async () => {
@@ -156,13 +165,15 @@ describe("partial integration failures", () => {
}),
]);
expect(latest.failedIntegrations).toEqual([
- expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "offline" }),
+ expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
]);
+ expect(JSON.stringify(latest)).not.toContain("secret");
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
expect(stats.failedIntegrations).toEqual([
- expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "offline" }),
+ expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
]);
+ expect(JSON.stringify(stats)).not.toContain("secret");
expect(stats.stats).toHaveLength(1);
expect(stats.users).toEqual([
expect.objectContaining({
diff --git a/packages/api/src/router/test/widgets/timetable.spec.ts b/packages/api/src/router/test/widgets/timetable.spec.ts
index e51ca1bef..aad001e9b 100644
--- a/packages/api/src/router/test/widgets/timetable.spec.ts
+++ b/packages/api/src/router/test/widgets/timetable.spec.ts
@@ -1,4 +1,6 @@
-import { stringify } from "superjson";
+// @vitest-environment node
+
+import { parse, stringify } from "superjson";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
@@ -8,6 +10,8 @@ import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
+import { boardRouter } from "../../board";
+import { optionsRouter } from "../../widgets/options";
import { timetableRouter } from "../../widgets/timetable";
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
@@ -123,7 +127,7 @@ describe("timetableRouter source authorization", () => {
"http://timetable.internal:8080",
"http://[::1]:8080",
"http://[::ffff:127.0.0.1]:8080",
- "http://[fd00::1]:8080",
+ "https://[fd00::1]:8443",
"http://[64:ff9b::7f00:1]:8080",
"http://[2002:7f00:1::]:8080",
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
@@ -182,6 +186,66 @@ describe("timetableRouter source authorization", () => {
);
});
+ it("blocks changing a saved timetable to a private endpoint through item options", async () => {
+ const db = createDb();
+ const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "https://search.ch",
+ isPublic: false,
+ });
+ const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
+
+ await expect(
+ caller.saveItemOptions({ boardId, itemId, newOptions: { baseUrl: "http://127.0.0.1:8080" } }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+
+ const item = await db.query.items.findFirst({ where: eq(items.id, itemId) });
+ expect(parse>(item?.options ?? "{}")).toMatchObject({ baseUrl: "https://search.ch" });
+ });
+
+ it("blocks changing a saved timetable to a private endpoint through a board save", async () => {
+ const db = createDb();
+ const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "https://search.ch",
+ isPublic: false,
+ });
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
+
+ await expect(
+ caller.saveBoard({
+ id: boardId,
+ sections: [],
+ items: [
+ {
+ id: itemId,
+ kind: "timetable",
+ options: { baseUrl: "http://127.0.0.1:8080" },
+ integrationIds: [],
+ layouts: [],
+ advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
+ },
+ ],
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("blocks adding a timetable with a private endpoint", async () => {
+ const db = createDb();
+ const { boardId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "https://search.ch",
+ isPublic: false,
+ });
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
+
+ await expect(
+ caller.addItem({
+ boardId,
+ kind: "timetable",
+ options: { baseUrl: "http://127.0.0.1:8080" },
+ integrationIds: [],
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
it("rejects an item ID that does not belong to the requested board", async () => {
const db = createDb();
const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
index 26806cba9..4265064b7 100644
--- a/packages/api/src/router/widgets/calendar.ts
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -45,14 +45,14 @@ export const calendarRouter = createTRPCRouter({
};
},
{
- fallback: (integration, error) => ({
+ fallback: (integration) => ({
events: [],
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
- error: error instanceof Error ? error.message : String(error),
+ error: "Integration request failed",
}),
throwOnAllFailures: true,
},
diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts
index 8000df4b8..4a68adb12 100644
--- a/packages/api/src/router/widgets/media-requests.ts
+++ b/packages/api/src/router/widgets/media-requests.ts
@@ -44,10 +44,10 @@ export const mediaRequestsRouter = createTRPCRouter({
};
},
{
- fallback: (integration, error) => ({
+ fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: [],
- error: error instanceof Error ? error.message : String(error),
+ error: "Integration request failed",
}),
throwOnAllFailures: true,
},
@@ -101,10 +101,10 @@ export const mediaRequestsRouter = createTRPCRouter({
};
},
{
- fallback: (integration, error) => ({
+ fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { stats: null, users: [] },
- error: error instanceof Error ? error.message : String(error),
+ error: "Integration request failed",
}),
throwOnAllFailures: true,
},
diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts
index 1ed6b9091..1ce8b863f 100644
--- a/packages/api/src/router/widgets/media-server.ts
+++ b/packages/api/src/router/widgets/media-server.ts
@@ -38,12 +38,12 @@ export const mediaServerRouter = createTRPCRouter({
};
},
{
- fallback: (integration, error) => ({
+ fallback: (integration) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: [],
- error: error instanceof Error ? error.message : String(error),
+ error: "Integration request failed",
}),
throwOnAllFailures: true,
},
diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts
index 873f1ab47..e39e5f1dc 100644
--- a/packages/api/src/router/widgets/options.ts
+++ b/packages/api/src/router/widgets/options.ts
@@ -9,6 +9,7 @@ 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({
@@ -46,12 +47,15 @@ export const optionsRouter = createTRPCRouter({
});
}
- const options = SuperJSON.parse>(item.options);
- const updatedOptions = { ...options, ...input.newOptions };
+ const previousOptions = SuperJSON.parse>(item.options);
+ const updatedOptions = { ...previousOptions, ...input.newOptions };
+ if (item.kind === "timetable") {
+ await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions);
+ }
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
- storedItems: [{ id: item.id, kind: item.kind, options }],
+ storedItems: [{ id: item.id, kind: item.kind, options: previousOptions }],
});
await ctx.db
.update(items)
diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts
index e92757fc0..0daf7a4d0 100644
--- a/packages/api/src/router/widgets/timetable.ts
+++ b/packages/api/src/router/widgets/timetable.ts
@@ -156,6 +156,34 @@ const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise) => {
+ 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);
@@ -174,6 +202,7 @@ const resolveTimetableBaseUrlAsync = async (
const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
let savedOptions: string | undefined;
if (input.itemId) {
const item = await ctx.db.query.items.findFirst({
@@ -182,8 +211,6 @@ const resolveTimetableBaseUrlAsync = async (
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
savedOptions = item.options;
}
-
- await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
const savedBaseUrl = savedOptions === undefined ? undefined : getSavedTimetableBaseUrl(savedOptions);
if (requestedBaseUrl === savedBaseUrl || requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
return { baseUrl: requestedBaseUrl };
diff --git a/packages/widgets/src/common/integration-error-indicator.tsx b/packages/widgets/src/common/integration-error-indicator.tsx
index fab368cad..818937ef4 100644
--- a/packages/widgets/src/common/integration-error-indicator.tsx
+++ b/packages/widgets/src/common/integration-error-indicator.tsx
@@ -3,7 +3,7 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
-import { useI18n } from "@homarr/translation/client";
+import { useScopedI18n } from "@homarr/translation/client";
interface IntegrationResult {
integrationId: string;
@@ -16,12 +16,12 @@ interface IntegrationErrorIndicatorProps {
}
export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) {
- const t = useI18n();
+ const t = useScopedI18n("common");
const failed = results.filter((result) => Boolean(result.error));
if (failed.length === 0) return null;
const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", ");
- const label = `${t("common.error")}: ${names}`;
+ const label = `${t("error")}: ${names}`;
return (
diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx
index a3d9dd3a2..1785c0dc1 100644
--- a/packages/widgets/src/rssFeed/component.tsx
+++ b/packages/widgets/src/rssFeed/component.tsx
@@ -12,7 +12,8 @@ import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
- const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input);
+ const { data: feedEntries = [], error } = clientApi.widget.rssFeed.getFeeds.useQuery(input);
+ if (error) throw error;
return feedEntries;
};
diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx
index 1f562dd36..428bacff7 100644
--- a/packages/widgets/src/umami/umami-content.tsx
+++ b/packages/widgets/src/umami/umami-content.tsx
@@ -185,7 +185,7 @@ export function UmamiContent({
{selectedView}
- {advancedLists}
+ {advancedLists}
);