Merge commit from fork
* fix(security): restrict rss-feed urls based on authentication * fix: lint issues * fix: incrase the limit to 100
This commit is contained in:
1 parent
9c6b2505a4
commit
fce970c706
3 files changed
+295
-4
No files matched your search
@@ -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<typeof items>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
callback: (urls: string[]) => restrictUrlsAsync(db, urls),
|
||||
db,
|
||||
};
|
||||
}
|
||||
@@ -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<WidgetComponentProps<"rssFeed">["options"]>(item.options))
|
||||
.flatMap((options) => options.feedUrls);
|
||||
return urls.filter((url) => configuredUrls.includes(url));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user