fix: harden v2 custom widgets and Workshop (#6480)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-31 13:28:53 +02:00
1 parent e8a2c87c2d
commit 557ebe0a2f
138 files changed
+5151 -1976

No files matched your search

@@ -1483,6 +1483,63 @@ describe("saveLayouts should save layout changes", () => {
});
});
describe("Custom Widget placement permissions", () => {
test("rejects a direct addItem request from a non-admin board modifier", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId } = await createFullBoardAsync(db, "custom-widget-add");
await expect(
caller.addItem({
boardId,
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("rejects a direct saveBoard request that adds a Custom Widget for a non-admin", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "custom-widget-save");
await expect(
caller.saveBoard({
id: boardId,
sections: [{ id: sectionId, kind: "empty", yOffset: 0, xOffset: 0 }],
items: [
{
id: createId(),
kind: "customApi",
options: {
definitionId: "definition-1",
refreshInterval: 30,
configuration: {},
},
integrationIds: [],
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
layouts: [
{
layoutId,
sectionId,
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
},
],
},
],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
@@ -9,24 +9,26 @@ import {
users,
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
const userId = createId();
const session = {
user: {
id: userId,
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const manageOnlySession = {
const nonAdminSession = {
...session,
user: { ...session.user, permissions: ["custom-widget-manage"] },
user: { ...session.user, permissions: ["board-modify-all"] },
} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
@@ -38,11 +40,11 @@ function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
function createManageOnlyCaller(db: ReturnType<typeof createDb>) {
function createNonAdminCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({
db,
deviceType: undefined,
session: manageOnlySession,
session: nonAdminSession,
});
}
@@ -54,14 +56,24 @@ async function prepareDatabase() {
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
BEFORE INSERT ON custom_widget_secret
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_secret_insert
BEFORE INSERT ON custom_widget_v2_secret
BEGIN
SELECT RAISE(ABORT, 'forced secret insert failure');
END`),
);
}
function rejectDefinitionUpdates(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_definition_update
BEFORE UPDATE ON custom_widget_v2_definition
BEGIN
SELECT RAISE(ABORT, 'forced definition update failure');
END`),
);
}
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const id = "legacy-jellyfin";
const encryptedValue = "preserved.secret" as `${string}.${string}`;
@@ -88,13 +100,21 @@ async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("atomically replaces a confirmed legacy widget while preserving its stable ID and credential", async () => {
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
expect(await db.query.legacyCustomWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
name: "Legacy Jellyfin",
enabled: false,
});
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
id: legacy.id,
enabled: false,
@@ -106,6 +126,20 @@ describe("custom widget definition persistence", () => {
kind: "apiKey",
encryptedValue: legacy.encryptedValue,
});
const migratedList = await createCaller(db).list();
expect(migratedList).toHaveLength(1);
expect(migratedList[0]).toMatchObject({ id: legacy.id, migrationRequired: false });
await createCaller(db).delete({ id: legacy.id });
const fallbackList = await createCaller(db).list();
expect(fallbackList).toHaveLength(1);
expect(fallbackList[0]).toMatchObject({ id: legacy.id, migrationRequired: true });
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
definitionId: legacy.id,
encryptedValue: legacy.encryptedValue,
});
});
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
@@ -149,6 +183,30 @@ describe("custom widget definition persistence", () => {
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("refuses to export a stored definition containing embedded credentials", async () => {
const db = await prepareDatabase();
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const counts = definition.requests.counts;
if (!counts) throw new Error("Jellyfin counts request is missing");
const id = createId();
await db.insert(customWidgetDefinitions).values({
id,
...serializeCustomWidgetDefinition({
...definition,
requests: {
...definition.requests,
counts: {
...counts,
headers: { ...counts.headers, "X-Auth": "Bearer sk-secret-123456" },
},
},
}),
creatorId: userId,
});
await expect(createCaller(db).export({ id })).rejects.toThrow("Credentials must use source authentication");
});
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -180,6 +238,69 @@ describe("custom widget definition persistence", () => {
expect(storedSecret).toEqual(originalSecret);
});
test("keeps the existing secret when setting its replacement fails", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectSecretInserts(db);
await expect(
caller.secretSet({
definitionId: created.id,
secret: { ...secret, value: "replacement-secret" },
}),
).rejects.toThrow("forced secret insert failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("keeps the existing secret when clearing it cannot update the definition revision", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectDefinitionUpdates(db);
await expect(
caller.secretClear({
definitionId: created.id,
sourceId: secret.sourceId,
kind: secret.kind,
}),
).rejects.toThrow("forced definition update failure");
expect(
await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
}),
).toEqual(originalDefinition);
expect(
await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toEqual(originalSecret);
});
test("configures a source URL and credential together", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
@@ -249,7 +370,7 @@ describe("custom widget definition persistence", () => {
};
await expect(
createManageOnlyCaller(db).previewCreate({
createNonAdminCaller(db).previewCreate({
definitionId: created.id,
definition: changedDefinition,
secrets: [],
@@ -260,11 +381,11 @@ describe("custom widget definition persistence", () => {
).resolves.toMatchObject({ success: true });
});
test("clears persisted credentials when a manage-only source edit changes its binding", async () => {
test("clears persisted credentials when an admin source edit changes its binding", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).sourceConfigure({
await createCaller(db).sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "https://other.example/api",
@@ -279,12 +400,109 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
test("uses the persisted source binding from inside the configuration transaction", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const defaultSource = definition.sources.default;
if (!defaultSource) throw new Error("Jellyfin default source is missing");
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: { ...defaultSource, baseUrl: "https://changed.example/api" },
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: defaultSource.baseUrl,
networkScope: defaultSource.networkScope,
secrets: [],
expectedSource: defaultSource,
}),
).resolves.toMatchObject({
status: "configured",
source: { baseUrl: defaultSource.baseUrl },
});
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: { default: { baseUrl: defaultSource.baseUrl } },
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("rejects stale setup-link credentials after a concurrent authentication binding change", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const expectedSource = definition.sources.default;
if (!expectedSource || typeof expectedSource.auth === "string") {
throw new Error("Jellyfin default source must use header authentication");
}
await db
.update(customWidgetDefinitions)
.set(
serializeCustomWidgetDefinition({
...definition,
name: "Concurrent definition edit",
sources: {
...definition.sources,
default: {
...expectedSource,
auth: { ...expectedSource.auth, name: "X-Replaced-Credential" },
},
},
}),
)
.where(eq(customWidgetDefinitions.id, created.id));
await expect(
configureCustomWidgetSourceFromRequest(db, {
definitionId: created.id,
sourceId: "default",
baseUrl: "https://configured.example/api",
networkScope: "public",
secrets: [secret],
expectedSource,
}),
).resolves.toEqual({ status: "binding-changed" });
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
name: "Concurrent definition edit",
sources: {
default: {
baseUrl: expectedSource.baseUrl,
auth: { name: "X-Replaced-Credential" },
},
},
});
expect(
await db.query.customWidgetSecrets.findMany({
where: eq(customWidgetSecrets.definitionId, created.id),
}),
).toHaveLength(0);
});
test("clears persisted credentials when a general definition update changes its binding", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
await createManageOnlyCaller(db).update({
await createCaller(db).update({
id: created.id,
sources: {
...jellyfin.sources,
@@ -299,12 +517,12 @@ describe("custom widget definition persistence", () => {
).toHaveLength(0);
});
test("requires secret-write permission to create a stored-definition setup link", async () => {
test("requires an administrator to create a stored-definition setup link", async () => {
const db = await prepareDatabase();
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
await expect(
createManageOnlyCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
createNonAdminCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { stringify as stringifySuperJson } from "superjson";
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
@@ -9,14 +10,14 @@ describe("legacy custom widget migration prompt", () => {
id: "legacy",
name: "Private service",
description: "Status",
iconUrl: null,
iconUrl: "https://icons:password@example.test/private/icon.svg?access_token=icon-secret",
url: "https://admin:password@example.test/hooks/path-token?api_key=top-secret&mode=full",
authType: "bearer",
headerName: null,
method: "POST",
requestBody: JSON.stringify({ token: "body-secret", limit: 5 }),
displayType: "singleValue",
displayConfig: JSON.stringify({ json: { type: "singleValue", jsonPath: "$.status" } }),
displayConfig: "corrupted-config-with-display-secret",
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
@@ -32,5 +33,40 @@ describe("legacy custom widget migration prompt", () => {
expect(prompt).not.toContain("body-secret");
expect(prompt).not.toContain("admin:password");
expect(prompt).not.toContain("path-token");
expect(prompt).not.toContain("icon-secret");
expect(prompt).not.toContain("icons:password");
expect(prompt).not.toContain("private/icon.svg");
expect(prompt).not.toContain("display-secret");
});
test("omits valid free-text display configuration from the migration prompt", () => {
const prompt = buildLegacyCustomWidgetMigrationPrompt(
{
id: "legacy-custom-jsx",
name: "Private service",
description: "Status",
iconUrl: null,
url: "https://example.test/api/status",
authType: "bearer",
headerName: null,
method: "GET",
requestBody: null,
displayType: "customJsx",
displayConfig: stringifySuperJson({
type: "customJsx",
template: "<Text>LEGACY_PRIVATE_MARKER Bearer sk-secret-123456</Text>",
}),
enabled: true,
createdAt: new Date(0),
updatedAt: new Date(0),
creatorId: null,
},
["apiKey"],
);
expect(prompt).toContain("display configuration was intentionally omitted");
expect(prompt).not.toContain("LEGACY_PRIVATE_MARKER");
expect(prompt).not.toContain("Bearer sk-secret-123456");
expect(prompt).not.toContain("sk-secret-123456");
});
});
@@ -0,0 +1,113 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
get: vi.fn(async (_id: string): Promise<unknown> => undefined),
list: vi.fn(async (_options: unknown): Promise<unknown> => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => mocks.logger,
}));
vi.mock("@homarr/workshop/backend", () => ({
WorkshopBackend: class {
public get(id: string) {
return mocks.get(id);
}
public list(options: unknown) {
return mocks.list(options);
}
},
}));
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
const session = {
user: {
id: createId(),
permissions: ["admin"],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const createCaller = () =>
customWidgetRouter.createCaller({
db: createDb(),
deviceType: undefined,
session,
});
const createTokenizedFailure = (apiKey: string) => {
const tokenizedUrl = `https://workshop.example/api/submissions?access_token=${apiKey}`;
const error = new Error(`Workshop request failed for ${tokenizedUrl}`);
error.name = `WorkshopTransportError:${tokenizedUrl}`;
return error;
};
const expectSafeFailure = (
error: unknown,
apiKey: string,
logMessage: string,
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
) => {
expect(error).toMatchObject({
code: "BAD_GATEWAY",
message: "Workshop is unavailable",
});
expect((error as Error).cause).toBeUndefined();
expect(String(error)).not.toContain(apiKey);
expect(String(error)).not.toContain("access_token");
expect(mocks.logger.error).toHaveBeenCalledWith(logMessage, {
event,
errorName: "WorkshopBackendError",
});
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(apiKey);
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain("access_token");
};
describe("Workshop backend error privacy", () => {
beforeEach(() => {
mocks.get.mockReset();
mocks.list.mockReset();
mocks.logger.error.mockClear();
});
test("redacts backend lookup failures from TRPC output and logs", async () => {
const apiKey = "workshop-lookup-secret";
mocks.get.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopGet({ submissionId: "submission-id" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget lookup failed", "workshop_widget_lookup_failed");
});
test("redacts backend search failures from TRPC output and logs", async () => {
const apiKey = "workshop-search-secret";
mocks.list.mockRejectedValueOnce(createTokenizedFailure(apiKey));
const error = await createCaller()
.workshopSearch({ query: "weather" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expectSafeFailure(error, apiKey, "Workshop widget search failed", "workshop_widget_search_failed");
});
});
@@ -0,0 +1,107 @@
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { optionsRouter } from "../../widgets/options";
const createSession = (userId: string, isAdmin = false): Session => ({
user: {
id: userId,
permissions: isAdmin ? ["admin"] : [],
colorScheme: "light",
},
expires: new Date(Date.now() + 60_000).toISOString(),
});
async function setup(kind: "customApi" | "weather") {
const db = createDb();
const userId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: userId });
await db.insert(items).values({
id: itemId,
boardId,
kind,
options: SuperJSON.stringify(
kind === "customApi"
? { definitionId: "definition-1", refreshInterval: 30, configuration: { server: "primary" } }
: { location: "Paris" },
),
});
return { db, userId, boardId, itemId };
}
describe("saveItemOptions Custom Widget access", () => {
test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: {
definitionId: "definition-2",
refreshInterval: 1,
configuration: { server: "attacker" },
},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({
definitionId: "definition-1",
refreshInterval: 30,
configuration: { server: "primary" },
});
});
test("allows a Homarr admin to reconfigure a Custom Widget placement", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
).resolves.toBeUndefined();
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
});
test("allows normalized missing defaults without treating them as reconfiguration", async () => {
const { db, userId, boardId, itemId } = await setup("customApi");
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
const options = SuperJSON.parse<Record<string, unknown>>(stored?.options ?? "");
delete options.configuration;
delete options.configurationVersion;
await db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.where(eq(items.id, itemId));
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({
boardId,
itemId,
newOptions: { configuration: {}, configurationVersion: 1 },
}),
).resolves.toBeUndefined();
});
test("preserves ordinary board-modifier option updates for other widgets", async () => {
const { db, userId, boardId, itemId } = await setup("weather");
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
await expect(
caller.saveItemOptions({ boardId, itemId, newOptions: { location: "Lyon" } }),
).resolves.toBeUndefined();
});
});