fix v2 hardening review findings
This commit is contained in:
1 parent
b82a6a7abc
commit
a7eae3ff81
35 files changed
+1147
-277
No files matched your search
@@ -5,3 +5,4 @@ export {
|
||||
parseStoredCustomWidgetDefinition,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "./router/custom-widget/stored-definition";
|
||||
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
|
||||
@@ -1,19 +1,35 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
import type {
|
||||
CustomWidgetCreateInput,
|
||||
CustomWidgetSecretKind,
|
||||
CustomWidgetSource,
|
||||
HomarrCustomWidgetV2,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetCreateInput, CustomWidgetSecretKind, CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { hasSameSecretBinding } from "./secret-policy";
|
||||
import { serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
import { assertSecretSources, hasSameSecretBinding } from "./secret-policy";
|
||||
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
|
||||
|
||||
type ConfigureCustomWidgetSourceInput = {
|
||||
definitionId: string;
|
||||
sourceId: string;
|
||||
baseUrl: string;
|
||||
networkScope?: CustomWidgetSource["networkScope"];
|
||||
secrets: CustomWidgetCreateInput["secrets"];
|
||||
expectedSource?: CustomWidgetSource;
|
||||
};
|
||||
|
||||
type SourceConfigurationIssue = "definition-not-found" | "source-not-found" | "binding-changed";
|
||||
|
||||
class SourceConfigurationError extends Error {
|
||||
constructor(readonly issue: SourceConfigurationIssue) {
|
||||
super(`Custom widget source configuration failed: ${issue}`);
|
||||
this.name = "SourceConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCustomWidgetSecret(db: Database, definitionId: string, secret: CustomWidgetSecretInput) {
|
||||
const updatedAt = new Date();
|
||||
const secretRow = {
|
||||
@@ -65,22 +81,39 @@ export async function setCustomWidgetSecret(db: Database, definitionId: string,
|
||||
});
|
||||
}
|
||||
|
||||
export async function configureCustomWidgetSource(
|
||||
export async function configureCustomWidgetSource(db: Database, input: ConfigureCustomWidgetSourceInput) {
|
||||
try {
|
||||
return await persistCustomWidgetSourceConfiguration(db, input);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SourceConfigurationError)) throw error;
|
||||
if (error.issue === "definition-not-found") throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (error.issue === "source-not-found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Widget source authentication changed while it was being configured",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureCustomWidgetSourceFromRequest(
|
||||
db: Database,
|
||||
input: {
|
||||
definitionId: string;
|
||||
sourceId: string;
|
||||
definition: HomarrCustomWidgetV2;
|
||||
previousSource: CustomWidgetSource;
|
||||
source: CustomWidgetSource;
|
||||
secrets: CustomWidgetCreateInput["secrets"];
|
||||
},
|
||||
input: ConfigureCustomWidgetSourceInput & { expectedSource: CustomWidgetSource },
|
||||
) {
|
||||
const definitionChanges = {
|
||||
...serializeCustomWidgetDefinition(input.definition),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const bindingChanged = !hasSameSecretBinding(input.previousSource, input.source);
|
||||
try {
|
||||
return {
|
||||
status: "configured" as const,
|
||||
source: await persistCustomWidgetSourceConfiguration(db, input),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof SourceConfigurationError)) throw error;
|
||||
return { status: error.issue } as const;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCustomWidgetSourceConfiguration(db: Database, input: ConfigureCustomWidgetSourceInput) {
|
||||
let configuredSource: CustomWidgetSource | undefined;
|
||||
const secretRows = input.secrets.map((secret) => ({
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
@@ -91,11 +124,19 @@ export async function configureCustomWidgetSource(
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
const [stored] = await transaction
|
||||
.select()
|
||||
.from(schema.customWidgetDefinitions)
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
const sourceUpdate = prepareSourceUpdate(stored, input);
|
||||
configuredSource = sourceUpdate.source;
|
||||
await transaction
|
||||
.update(schema.customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.set(sourceUpdate.definitionChanges)
|
||||
.where(eq(schema.customWidgetDefinitions.id, input.definitionId));
|
||||
if (bindingChanged) {
|
||||
if (sourceUpdate.bindingChanged) {
|
||||
await transaction
|
||||
.delete(schema.customWidgetSecrets)
|
||||
.where(
|
||||
@@ -120,39 +161,82 @@ export async function configureCustomWidgetSource(
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction((transaction) => {
|
||||
transaction
|
||||
.update(customWidgetDefinitions)
|
||||
.set(definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
if (bindingChanged) {
|
||||
database.transaction(
|
||||
(transaction) => {
|
||||
const stored = transaction
|
||||
.select()
|
||||
.from(customWidgetDefinitions)
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.limit(1)
|
||||
.get();
|
||||
const sourceUpdate = prepareSourceUpdate(stored, input);
|
||||
configuredSource = sourceUpdate.source;
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
),
|
||||
)
|
||||
.update(customWidgetDefinitions)
|
||||
.set(sourceUpdate.definitionChanges)
|
||||
.where(eq(customWidgetDefinitions.id, input.definitionId))
|
||||
.run();
|
||||
}
|
||||
for (const secret of secretRows) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
});
|
||||
if (sourceUpdate.bindingChanged) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
for (const secret of secretRows) {
|
||||
transaction
|
||||
.delete(customWidgetSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(customWidgetSecrets.definitionId, input.definitionId),
|
||||
eq(customWidgetSecrets.sourceId, input.sourceId),
|
||||
eq(customWidgetSecrets.kind, secret.kind),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
transaction.insert(customWidgetSecrets).values(secret).run();
|
||||
}
|
||||
},
|
||||
{ behavior: "immediate" },
|
||||
);
|
||||
},
|
||||
});
|
||||
if (!configuredSource) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
return configuredSource;
|
||||
}
|
||||
|
||||
function prepareSourceUpdate(
|
||||
stored: Parameters<typeof parseStoredCustomWidgetDefinition>[0] | undefined,
|
||||
input: ConfigureCustomWidgetSourceInput,
|
||||
) {
|
||||
if (!stored) throw new SourceConfigurationError("definition-not-found");
|
||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||
const previousSource = definition.sources[input.sourceId];
|
||||
if (!previousSource) throw new SourceConfigurationError("source-not-found");
|
||||
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
|
||||
throw new SourceConfigurationError("binding-changed");
|
||||
}
|
||||
const source = customWidgetSourceSchema.parse({
|
||||
...previousSource,
|
||||
baseUrl: input.baseUrl,
|
||||
networkScope: input.networkScope ?? previousSource.networkScope,
|
||||
});
|
||||
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
||||
return {
|
||||
source,
|
||||
bindingChanged: !hasSameSecretBinding(previousSource, source),
|
||||
definitionChanges: {
|
||||
...serializeCustomWidgetDefinition({
|
||||
...definition,
|
||||
sources: { ...definition.sources, [input.sourceId]: source },
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearCustomWidgetSecret(
|
||||
|
||||
@@ -80,20 +80,18 @@ export const secretProcedures = {
|
||||
networkScope: input.networkScope ?? current.networkScope,
|
||||
});
|
||||
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
||||
const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } };
|
||||
await configureCustomWidgetSource(ctx.db, {
|
||||
const configuredSource = await configureCustomWidgetSource(ctx.db, {
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
definition: updated,
|
||||
previousSource: current,
|
||||
source,
|
||||
baseUrl: source.baseUrl,
|
||||
networkScope: input.networkScope,
|
||||
secrets: input.secrets,
|
||||
});
|
||||
return {
|
||||
definitionId: input.definitionId,
|
||||
sourceId: input.sourceId,
|
||||
baseUrl: source.baseUrl,
|
||||
networkScope: source.networkScope,
|
||||
baseUrl: configuredSource.baseUrl,
|
||||
networkScope: configuredSource.networkScope,
|
||||
configuredSecrets: input.secrets.map(({ kind }) => kind),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,7 @@ 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();
|
||||
@@ -399,6 +400,103 @@ 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);
|
||||
|
||||
Reference in new issue
Block a user