fix(custom-widgets): finish v2 hardening
This commit is contained in:
97 files changed
+2833
-12393
No files matched your search
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { WorkshopClient } from "./client";
|
||||
|
||||
describe("Workshop deletion", () => {
|
||||
const cases: Array<{
|
||||
label: string;
|
||||
action: (client: WorkshopClient) => Promise<void>;
|
||||
collection: string;
|
||||
id: string;
|
||||
}> = [
|
||||
{
|
||||
label: "submission",
|
||||
action: (client) => client.delete("submission-id"),
|
||||
collection: "submissions",
|
||||
id: "submission-id",
|
||||
},
|
||||
{ label: "report", action: (client) => client.dismissReport("report-id"), collection: "reports", id: "report-id" },
|
||||
];
|
||||
|
||||
test.each(cases)("deletes $label records through PocketBase", async ({ action, collection: collectionName, id }) => {
|
||||
const client = new WorkshopClient("https://workshop.example.com");
|
||||
const deleteRecord = vi.fn().mockResolvedValue(true);
|
||||
const collection = vi.spyOn(client.pocketBase, "collection").mockReturnValue({ delete: deleteRecord } as never);
|
||||
|
||||
await action(client);
|
||||
|
||||
expect(collection).toHaveBeenCalledWith(collectionName);
|
||||
expect(deleteRecord).toHaveBeenCalledWith(id);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import PocketBase, { ClientResponseError } from "pocketbase";
|
||||
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
|
||||
import type {
|
||||
WorkshopReport,
|
||||
WorkshopSubmissionDetail,
|
||||
@@ -11,7 +13,6 @@ import type {
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WORKSHOP_REQUEST_TIMEOUT_MS,
|
||||
WorkshopError,
|
||||
workshopReportSchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
workshopSubmissionSummarySchema,
|
||||
@@ -21,8 +22,6 @@ import {
|
||||
workshopVoteSchema,
|
||||
} from "./schema";
|
||||
|
||||
const WORKSHOP_OAUTH_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
export interface WorkshopListOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@@ -47,32 +46,34 @@ const requestSignal = (signal?: AbortSignal) =>
|
||||
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
|
||||
);
|
||||
|
||||
function workshopError(error: unknown, fallback: string) {
|
||||
if (error instanceof WorkshopError) return error;
|
||||
if (error instanceof ClientResponseError) {
|
||||
const code =
|
||||
error.status === 401
|
||||
? "authentication_required"
|
||||
: error.status === 403
|
||||
? "forbidden"
|
||||
: error.status === 404
|
||||
? "not_found"
|
||||
: error.status === 409
|
||||
? "conflict"
|
||||
: error.status === 429
|
||||
? "rate_limited"
|
||||
: error.status >= 500
|
||||
? "unavailable"
|
||||
: "unknown";
|
||||
return new WorkshopError(code, error.data?.message || error.message || fallback, error.status);
|
||||
}
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError"))
|
||||
return new WorkshopError("unavailable", `${fallback}. The request timed out.`);
|
||||
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
|
||||
const OAUTH_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
function oauthText(...values: unknown[]) {
|
||||
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]) {
|
||||
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
|
||||
function githubProfile(meta: Record<string, unknown> | undefined, record: Record<string, unknown>) {
|
||||
const rawUser =
|
||||
meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record<string, unknown>) : undefined;
|
||||
const displayName = oauthText(meta?.name, meta?.username, rawUser?.name, rawUser?.login);
|
||||
const avatarUrl = oauthText(meta?.avatarUrl, meta?.avatarURL, rawUser?.avatar_url);
|
||||
return {
|
||||
displayName: (displayName ?? `GitHub user ${String(record.id ?? "").slice(0, 8)}`).slice(0, 100),
|
||||
avatarUrl: avatarUrl?.startsWith("https://") ? avatarUrl : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function workshopError(error: unknown, fallback: string) {
|
||||
if (error instanceof ClientResponseError) {
|
||||
const sdkMessage = error.data?.message || error.message;
|
||||
const originalMessage =
|
||||
error.originalError instanceof Error ? error.originalError.message : String(error.originalError?.message ?? "");
|
||||
const message = !sdkMessage || sdkMessage === "Something went wrong." ? originalMessage || fallback : sdkMessage;
|
||||
return new Error(message, { cause: error });
|
||||
}
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError"))
|
||||
return new Error(`${fallback}. The request timed out.`, { cause: error });
|
||||
return error instanceof Error ? error : new Error(fallback);
|
||||
}
|
||||
|
||||
export class WorkshopClient {
|
||||
@@ -102,83 +103,67 @@ export class WorkshopClient {
|
||||
try {
|
||||
await this.pocketBase.collection("users").authRefresh();
|
||||
return this.currentUser;
|
||||
} catch {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof ClientResponseError && (error.status === 401 || error.status === 403)) {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
}
|
||||
return this.currentUser;
|
||||
}
|
||||
}
|
||||
|
||||
public async signInWithGitHub() {
|
||||
if (typeof window === "undefined" || typeof window.open !== "function") {
|
||||
throw new WorkshopError("unavailable", "GitHub sign-in requires a browser popup");
|
||||
throw new Error("GitHub sign-in requires a browser popup");
|
||||
}
|
||||
|
||||
const popup = window.open(
|
||||
"about:blank",
|
||||
"homarr_workshop_oauth",
|
||||
"width=1024,height=768,resizable=yes,scrollbars=yes,menubar=no",
|
||||
);
|
||||
if (!popup) throw new WorkshopError("unavailable", "GitHub sign-in popup was blocked by the browser");
|
||||
const popup = window.open("about:blank", "homarr_workshop_oauth", "width=1024,height=768,resizable,menubar=no");
|
||||
if (!popup) throw new Error("GitHub sign-in popup was blocked. Allow popups for Homarr and try again.");
|
||||
|
||||
const requestKey = `workshop_oauth_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const requestKey = `workshop-oauth-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
let cancellationError: Error | null = null;
|
||||
let closePoll: ReturnType<typeof setInterval> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const popupFailure = new Promise<never>((_resolve, reject) => {
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
closePoll = setInterval(() => {
|
||||
if (popup.closed) reject(new WorkshopError("unknown", "GitHub sign-in was cancelled"));
|
||||
if (!popup.closed) return;
|
||||
cancellationError = new Error("GitHub sign-in was cancelled.");
|
||||
this.pocketBase.cancelRequest(requestKey);
|
||||
reject(cancellationError);
|
||||
}, 250);
|
||||
timeout = setTimeout(
|
||||
() => reject(new WorkshopError("unavailable", "GitHub sign-in timed out")),
|
||||
WORKSHOP_OAUTH_TIMEOUT_MS,
|
||||
);
|
||||
timeout = setTimeout(() => {
|
||||
cancellationError = new Error("GitHub sign-in timed out. Try again.");
|
||||
this.pocketBase.cancelRequest(requestKey);
|
||||
popup.close();
|
||||
reject(cancellationError);
|
||||
}, OAUTH_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
try {
|
||||
const auth = await Promise.race([
|
||||
this.pocketBase.collection("users").authWithOAuth2({
|
||||
provider: "github",
|
||||
createData: { displayName: "Community member" },
|
||||
createData: { displayName: "GitHub user" },
|
||||
requestKey,
|
||||
urlCallback: (url) => {
|
||||
if (popup.closed) throw new WorkshopError("unknown", "GitHub sign-in was cancelled");
|
||||
if (popup.closed) throw new Error("GitHub sign-in was cancelled.");
|
||||
popup.location.href = url;
|
||||
},
|
||||
}),
|
||||
popupFailure,
|
||||
cancellation,
|
||||
]);
|
||||
const rawUser =
|
||||
auth.meta?.rawUser && typeof auth.meta.rawUser === "object"
|
||||
? (auth.meta.rawUser as Record<string, unknown>)
|
||||
: undefined;
|
||||
const displayName = firstNonEmptyString(
|
||||
auth.meta?.name,
|
||||
auth.meta?.username,
|
||||
rawUser?.name,
|
||||
rawUser?.login,
|
||||
auth.record.name,
|
||||
auth.record.username,
|
||||
);
|
||||
const avatarUrl = firstNonEmptyString(
|
||||
auth.meta?.avatarUrl,
|
||||
auth.meta?.avatar,
|
||||
rawUser?.avatar_url,
|
||||
auth.record.avatarUrl,
|
||||
auth.record.avatar,
|
||||
);
|
||||
if (displayName || avatarUrl) {
|
||||
await this.pocketBase.collection("users").update(auth.record.id, {
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
});
|
||||
}
|
||||
return this.refreshAuth();
|
||||
const profile = githubProfile(auth.meta, auth.record);
|
||||
const updated = await this.pocketBase.collection("users").update(auth.record.id, profile);
|
||||
this.pocketBase.authStore.save(auth.token, updated);
|
||||
return this.currentUser;
|
||||
} catch (error) {
|
||||
if (cancellationError) throw cancellationError;
|
||||
throw workshopError(error, "GitHub sign-in failed");
|
||||
} finally {
|
||||
if (closePoll) clearInterval(closePoll);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.pocketBase.cancelRequest(requestKey);
|
||||
if (!popup.closed) popup.close();
|
||||
popup.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +229,7 @@ export class WorkshopClient {
|
||||
workshopScreenshotsSchema.parse(screenshots);
|
||||
const data = new FormData();
|
||||
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
|
||||
data.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
|
||||
data.set("author", this.currentUser?.id ?? "");
|
||||
screenshots.forEach((file) => data.append("screenshots", file));
|
||||
try {
|
||||
@@ -259,6 +245,7 @@ export class WorkshopClient {
|
||||
workshopScreenshotsSchema.parse(screenshots);
|
||||
const data = new FormData();
|
||||
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
|
||||
data.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
|
||||
screenshots.forEach((file) => data.append("screenshots", file));
|
||||
try {
|
||||
await this.pocketBase.collection("submissions").update(id, data);
|
||||
|
||||
@@ -34,6 +34,7 @@ const workshopSubmissionBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().default(""),
|
||||
widgetSchema: z.string(),
|
||||
screenshots: workshopFileListSchema,
|
||||
author: z.string(),
|
||||
authorName: z.string(),
|
||||
@@ -106,55 +107,12 @@ export function validateWorkshopWidget(content: string): WorkshopValidationResul
|
||||
.join("\n");
|
||||
return { success: false, error: error || "Invalid widget" };
|
||||
}
|
||||
if (containsCredentialLikeValue(result.data)) {
|
||||
return { success: false, error: "Widget exports must not contain credentials or credential-like static values" };
|
||||
}
|
||||
return { success: true, data: result.data };
|
||||
} catch {
|
||||
return { success: false, error: "Widget content is not valid JSON" };
|
||||
}
|
||||
}
|
||||
|
||||
function containsCredentialLikeValue(widget: z.infer<typeof customWidgetImportSchema>) {
|
||||
const sensitiveName =
|
||||
/(^|[-_])(authorization|api[-_]?keys?|passwords?|passwds?|secrets?|tokens?|access[-_]?tokens?|refresh[-_]?tokens?|client[-_]?secrets?)($|[-_])/iu;
|
||||
return (
|
||||
credentialTextPattern.test(widget.template) ||
|
||||
widget.sources.some((source) => {
|
||||
const url = new URL(source.baseUrl);
|
||||
return Boolean(url.username || url.password || url.search || url.hash);
|
||||
}) ||
|
||||
(widget.iconUrl
|
||||
? [...new URL(widget.iconUrl).searchParams].some(
|
||||
([name, value]) => sensitiveName.test(name) && value.trim().length > 0,
|
||||
)
|
||||
: false) ||
|
||||
widget.requests.some(
|
||||
(request) =>
|
||||
Object.entries(request.staticHeaders ?? {}).some(
|
||||
([name, value]) =>
|
||||
(sensitiveName.test(name) && value.trim().length > 0) || /^\s*bearer\s+\S{8,}\s*$/iu.test(value),
|
||||
) ||
|
||||
containsCredential(request.optionsBinding, "", sensitiveName) ||
|
||||
containsCredential(request.bodyTemplate, "", sensitiveName) ||
|
||||
containsCredential(request.queryTemplate, "", sensitiveName),
|
||||
) ||
|
||||
containsCredential(widget.defaultOptions, "", sensitiveName)
|
||||
);
|
||||
}
|
||||
|
||||
const credentialTextPattern =
|
||||
/(?:\bauthorization\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._~+/%-]{8,}|\b(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|token|password|passwd|secret)\s*[:=]\s*["'][^"']{4,}["'])/iu;
|
||||
|
||||
function containsCredential(value: unknown, key: string, pattern: RegExp): boolean {
|
||||
if (pattern.test(key) && typeof value === "string" && value.trim().length > 0) return true;
|
||||
if (Array.isArray(value)) return value.some((entry) => containsCredential(entry, "", pattern));
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") return false;
|
||||
return entries.some(([childKey, child]) => containsCredential(child, childKey, pattern));
|
||||
}
|
||||
|
||||
export function workshopExportFilename(title: string) {
|
||||
const safe =
|
||||
title
|
||||
@@ -164,21 +122,3 @@ export function workshopExportFilename(title: string) {
|
||||
.replaceAll(/^-|-$/g, "") || "homarr-widget";
|
||||
return `${safe}.json`;
|
||||
}
|
||||
|
||||
export class WorkshopError extends Error {
|
||||
public constructor(
|
||||
public readonly code:
|
||||
| "authentication_required"
|
||||
| "forbidden"
|
||||
| "not_found"
|
||||
| "conflict"
|
||||
| "rate_limited"
|
||||
| "unavailable"
|
||||
| "unknown",
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkshopError";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user