fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 01:20:27 +02:00
1 parent 576cc0b418
commit 07770e862c
44 files changed
+3003 -1000

No files matched your search

+26 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { onboardingCreateIntegrationSchema, onboardingDiscoveredAppSchema } from "./onboarding";
import {
onboardingCompleteSetupSchema,
onboardingCreateIntegrationSchema,
onboardingDiscoveredAppSchema,
} from "./onboarding";
describe("onboarding URL validation", () => {
test.each(["javascript:alert(1)", "data:text/html,hello", "file:///etc/passwd"])(
@@ -53,4 +57,25 @@ describe("onboarding URL validation", () => {
}).success,
).toBe(false);
});
test("requires every app submitted to completeSetup to have an HTTP(S) href", () => {
const input = {
server: { defaultLocale: "en", defaultColorScheme: "dark" },
board: {
name: "dashboard",
primaryColor: "#228BE6",
secondaryColor: "#15AABF",
itemRadius: "md",
},
apps: [{ sourceId: "docker:status", name: "Status", href: null, iconUrl: null }],
};
expect(onboardingCompleteSetupSchema.safeParse(input).success).toBe(false);
expect(
onboardingCompleteSetupSchema.safeParse({
...input,
apps: [{ ...input.apps[0], href: "https://status.example.com" }],
}).success,
).toBe(true);
});
});
+80 -29
View File
@@ -1,9 +1,16 @@
import { z } from "zod/v4";
import { colorSchemes, onboardingLayoutPresets, widgetKinds } from "@homarr/definitions";
import {
colorSchemes,
integrationKinds,
integrationSecretKinds,
onboardingLayoutPresets,
widgetKinds,
} from "@homarr/definitions";
import { supportedLanguages } from "@homarr/translation/languages";
import { boardNameSchema } from "./board";
import { zodEnumFromArray } from "./enums";
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
const httpUrlSchema = z.url().refine((value) => {
@@ -14,44 +21,30 @@ const uniqueIdsSchema = z
.array(z.string().trim().min(1).max(255))
.max(128)
.refine((ids) => new Set(ids).size === ids.length, { message: "IDs must be unique" });
export const onboardingCompleteSetupSchema = z.object({
server: z.object({
defaultLocale: z.enum(supportedLanguages),
defaultColorScheme: z.enum(colorSchemes),
analyticsEnabled: z.boolean().optional(),
}),
board: z.object({
id: z.string().trim().min(1).max(255).optional(),
name: boardNameSchema,
primaryColor: hexColorSchema,
secondaryColor: hexColorSchema,
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
leftSidebar: z.boolean().default(false),
rightSidebar: z.boolean().default(false),
}),
selectedIntegrationIds: uniqueIdsSchema.default([]),
selectedAppIds: uniqueIdsSchema.default([]),
selectedDockerSourceIds: uniqueIdsSchema.default([]),
selectedWidgetKinds: z
.array(z.enum(widgetKinds))
.max(widgetKinds.length)
.refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" })
.default([]),
});
const sourceIdSchema = z.string().trim().min(1).max(512);
export const onboardingCreateIntegrationSchema = z.object({
name: z.string().trim().min(1).max(127),
url: httpUrlSchema,
sourceId: z.string().trim().min(1).max(512).optional(),
sourceId: sourceIdSchema.optional(),
iconUrl: z.string().trim().min(1).max(2048).nullable().optional(),
description: z.string().trim().max(512).nullable().optional(),
pingUrl: httpUrlSchema.nullable().optional(),
});
export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchema.extend({
sourceId: sourceIdSchema,
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
kind: zodEnumFromArray(integrationSecretKinds),
value: z.string().nonempty(),
}),
),
});
export const onboardingDiscoveredAppSchema = z.object({
sourceId: z.string().trim().min(1).max(512).optional(),
sourceId: sourceIdSchema.optional(),
name: z.string().trim().min(1).max(127),
href: httpUrlSchema.nullable(),
pingUrl: httpUrlSchema.nullable().optional(),
@@ -59,4 +52,62 @@ export const onboardingDiscoveredAppSchema = z.object({
description: z.string().trim().max(512).nullable().optional(),
});
export const onboardingAppDraftSchema = onboardingDiscoveredAppSchema.extend({
sourceId: sourceIdSchema,
href: httpUrlSchema,
});
const integrationDraftsSchema = z
.array(onboardingIntegrationDraftSchema)
.max(128)
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
message: "Integration source IDs must be unique",
});
const appDraftsSchema = z
.array(onboardingAppDraftSchema)
.max(128)
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
message: "App source IDs must be unique",
});
export const onboardingCompleteSetupSchema = z
.object({
server: z.object({
defaultLocale: z.enum(supportedLanguages),
defaultColorScheme: z.enum(colorSchemes),
analyticsEnabled: z.boolean().optional(),
}),
board: z.object({
id: z.string().trim().min(1).max(255).optional(),
name: boardNameSchema,
primaryColor: hexColorSchema,
secondaryColor: hexColorSchema,
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
leftSidebar: z.boolean().default(false),
rightSidebar: z.boolean().default(false),
}),
selectedIntegrationIds: uniqueIdsSchema.default([]),
selectedAppIds: uniqueIdsSchema.default([]),
integrations: integrationDraftsSchema.default([]),
apps: appDraftsSchema.default([]),
selectedDockerSourceIds: uniqueIdsSchema.default([]),
selectedWidgetKinds: z
.array(z.enum(widgetKinds))
.max(widgetKinds.length)
.refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" })
.default([]),
})
.superRefine((input, ctx) => {
const integrationSourceIds = new Set(input.integrations.map((integration) => integration.sourceId));
for (const [index, app] of input.apps.entries()) {
if (!integrationSourceIds.has(app.sourceId)) continue;
ctx.addIssue({
code: "custom",
message: "A source ID cannot be used by both an integration and an app",
path: ["apps", index, "sourceId"],
});
}
});
export type OnboardingCompleteSetupInput = z.infer<typeof onboardingCompleteSetupSchema>;