From fce970c70653f200ff1c73081139a77f0379bd91 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 27 Feb 2026 19:13:43 +0100 Subject: [PATCH] Merge commit from fork * fix(security): restrict rss-feed urls based on authentication * fix: lint issues * fix: incrase the limit to 100 --- .../src/router/test/widgets/rssFeed.spec.ts | 221 ++++++++++++++++++ packages/api/src/router/widgets/rssFeed.ts | 76 +++++- packages/request-handler/src/rss-feeds.ts | 2 +- 3 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/router/test/widgets/rssFeed.spec.ts diff --git a/packages/api/src/router/test/widgets/rssFeed.spec.ts b/packages/api/src/router/test/widgets/rssFeed.spec.ts new file mode 100644 index 000000000..acd7f5c17 --- /dev/null +++ b/packages/api/src/router/test/widgets/rssFeed.spec.ts @@ -0,0 +1,221 @@ +import SuperJSON from "superjson"; +import { describe, expect, test } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { InferInsertModel } from "@homarr/db"; +import { + boardGroupPermissions, + boards, + boardUserPermissions, + groupMembers, + groups, + items, + users, +} from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import type { GroupPermissionKey } from "@homarr/definitions"; + +import type { WidgetComponentProps } from "../../../../../widgets/src/definition"; +import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed"; + +const createSession = (permissions: GroupPermissionKey[] = []): Session => ({ + user: { + id: "user-id", + colorScheme: "light", + permissions, + }, + expires: new Date().toISOString(), +}); + +describe("rssFeedRouter", () => { + describe("canAccessAllFeedsAsync", () => { + test("should return false for unauthenticated users", async () => { + // Arrange + const { callback } = setupAccessCheck(); + + // Act + const result = await callback(/* session: */ null); + + // Assert + expect(result).toBe(false); + }); + test.each([["board-create" as const], ["board-modify-all" as const]])( + "should return true for users with %s permission", + async (permission) => { + // Arrange + const { callback } = setupAccessCheck(); + const session = createSession([permission]); + + // Act + const result = await callback(session); + + // Assert + expect(result).toBe(true); + }, + ); + test("should return false for authenticated users without any boards or permissions", async () => { + // Arrange + const { callback, db } = setupAccessCheck(); + const session = createSession(); + await db.insert(users).values({ + id: session.user.id, + }); + + // Act + const result = await callback(session); + + // Assert + expect(result).toBe(false); + }); + test("should return true for authenticated users that own at least one board", async () => { + // Arrange + const { callback, db } = setupAccessCheck(); + const session = createSession(); + await db.insert(users).values({ + id: session.user.id, + }); + await db.insert(boards).values({ + id: createId(), + name: "test", + creatorId: session.user.id, + }); + + // Act + const result = await callback(session); + + // Assert + expect(result).toBe(true); + }); + test.each([["modify" as const], ["full" as const]])( + "should return true for authenticated users that have %s permissions on at least one board", + async (permission) => { + // Arrange + const { callback, db } = setupAccessCheck(); + const session = createSession(); + await db.insert(users).values({ + id: session.user.id, + }); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "test", + }); + await db.insert(boardUserPermissions).values({ + boardId, + permission, + userId: session.user.id, + }); + + // Act + const result = await callback(session); + + // Assert + expect(result).toBe(true); + }, + ); + test.each([["modify" as const], ["full" as const]])( + "should return true for authenticated users that have %s permissions through group on at least one board", + async (permission) => { + // Arrange + const { callback, db } = setupAccessCheck(); + const session = createSession(); + await db.insert(users).values({ + id: session.user.id, + }); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "test", + }); + const groupId = createId(); + await db.insert(groups).values({ + id: groupId, + name: "test-group", + position: 0, + }); + await db.insert(groupMembers).values({ + groupId, + userId: session.user.id, + }); + await db.insert(boardGroupPermissions).values({ + boardId, + groupId, + permission, + }); + + // Act + const result = await callback(session); + + // Assert + expect(result).toBe(true); + }, + ); + }); + + describe("restrictUrlsAsync", () => { + test("should return empty array if there are no rss feed items in the database", async () => { + // Arrange + const { callback } = await setupUrlRestrictionAsync([]); + + // Act + const result = await callback(["http://example.com/feed"]); + + // Assert + expect(result).toEqual([]); + }); + + test("should return only the URLs that are present in the database", async () => { + // Arrange + const { callback } = await setupUrlRestrictionAsync(["http://example.com/feed"]); + + // Act + const result = await callback(["http://example.com/feed", "http://not-in-db.com/feed"]); + + // Assert + expect(result).toEqual(["http://example.com/feed"]); + }); + }); +}); + +function setupAccessCheck() { + const db = createDb(); + + return { + callback: (session: Session | null) => canAccessAllFeedsAsync(db, session), + db, + }; +} + +async function setupUrlRestrictionAsync(dbUrls: string[]) { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "test", + }); + if (dbUrls.length >= 1) { + await db.insert(items).values( + dbUrls.map( + (url) => + ({ + id: createId(), + kind: "rssFeed", + boardId, + options: SuperJSON.stringify({ + feedUrls: [url], + enableRtl: false, + hideDescription: false, + textLinesClamp: 3, + maximumAmountPosts: 5, + } satisfies WidgetComponentProps<"rssFeed">["options"]), + }) satisfies InferInsertModel, + ), + ); + } + + return { + callback: (urls: string[]) => restrictUrlsAsync(db, urls), + db, + }; +} diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index b66662c2e..079d84a32 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -1,20 +1,30 @@ +import SuperJSON from "superjson"; import { z } from "zod/v4"; +import type { Session } from "@homarr/auth"; +import type { Database } from "@homarr/db"; +import { eq } from "@homarr/db"; +import { items, users } from "@homarr/db/schema"; import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds"; +import type { WidgetComponentProps } from "../../../../widgets/src"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const rssFeedRouter = createTRPCRouter({ getFeeds: publicProcedure .input( z.object({ - urls: z.array(z.string()), + urls: z.array(z.string()).max(100), maximumAmountPosts: z.number(), }), ) - .query(async ({ input }) => { + .query(async ({ ctx, input }) => { + const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session)) + ? input.urls + : await restrictUrlsAsync(ctx.db, input.urls); + const rssFeeds = await Promise.all( - input.urls.map(async (url) => { + urls.map(async (url) => { const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts, @@ -35,3 +45,63 @@ export const rssFeedRouter = createTRPCRouter({ }); }), }); + +export async function canAccessAllFeedsAsync(db: Database, session: Session | null) { + // Unauthenticated users can never access all feeds + if (!session) return false; + // Users with those permissions can modify a board and therefore add a rss feed widget with any feed url + if (session.user.permissions.includes("board-create") || session.user.permissions.includes("board-modify-all")) { + return true; + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, session.user.id), + with: { + boards: { + columns: { id: true }, + }, + boardPermissions: true, + groups: { + with: { + group: { + with: { boardPermissions: true }, + }, + }, + }, + }, + }); + + // Should never happen as the user is authenticated, but just in case + if (!user) return false; + + // If user is owner of any board he has full access and can therefore create widgets with any feed url + if (user.boards.length >= 1) return true; + + // If user has direct permissions on any board that allow modifying it, he can add a rss feed widget with any feed url + if (user.boardPermissions.some(({ permission }) => permission === "modify" || permission === "full")) { + return true; + } + + // If user is in a group that has permissions on any board that allow modifying it, he can add a rss feed widget with any feed url + if ( + user.groups.some(({ group }) => + group.boardPermissions.some(({ permission }) => permission === "modify" || permission === "full"), + ) + ) { + return true; + } + + // Fallback to not allowing access to all feeds + return false; +} + +export async function restrictUrlsAsync(db: Database, urls: string[]) { + const rssFeedItems = await db.query.items.findMany({ + where: eq(items.kind, "rssFeed"), + }); + + const configuredUrls = rssFeedItems + .map((item) => SuperJSON.parse["options"]>(item.options)) + .flatMap((options) => options.feedUrls); + return urls.filter((url) => configuredUrls.includes(url)); +} diff --git a/packages/request-handler/src/rss-feeds.ts b/packages/request-handler/src/rss-feeds.ts index e1c9f68c5..eb13caa88 100644 --- a/packages/request-handler/src/rss-feeds.ts +++ b/packages/request-handler/src/rss-feeds.ts @@ -108,7 +108,7 @@ const getFirstMediaProperty = (feedObject: object) => { * We extend the feed with custom properties. * This interface adds properties on top of the default ones. */ -interface ExtendedFeedEntry extends FeedEntry { +export interface ExtendedFeedEntry extends FeedEntry { enclosure?: string; }