From 20be2a9a035c9cc1eae54ed2063bdbc4ddb112d0 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 24 Aug 2026 00:19:00 +0200 Subject: [PATCH] fix(clipboard): route copy actions through Electron Replace renderer clipboard calls with the validated main-process IPC writer for link names, URLs, package batches, backup keys, diagnostics, error details, and masked account identifiers. Raise the validated payload limit to one MiB so complete link packages copy without truncation while preserving empty and oversized input rejection. --- src/main/clipboard-write.ts | 15 ++++ src/main/main.ts | 20 ++--- src/renderer/App.tsx | 65 +++++--------- src/renderer/ui/LinkAddressesDialog.tsx | 72 +++++++++++++++ tests/app-shell.test.tsx | 8 +- tests/clipboard-write.test.ts | 15 ++++ tests/link-addresses-dialog.test.tsx | 115 ++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 57 deletions(-) create mode 100644 src/main/clipboard-write.ts create mode 100644 src/renderer/ui/LinkAddressesDialog.tsx create mode 100644 tests/clipboard-write.test.ts create mode 100644 tests/link-addresses-dialog.test.tsx diff --git a/src/main/clipboard-write.ts b/src/main/clipboard-write.ts new file mode 100644 index 0000000..213783e --- /dev/null +++ b/src/main/clipboard-write.ts @@ -0,0 +1,15 @@ +export const CLIPBOARD_WRITE_MAX_BYTES = 1024 * 1024; + +export function validateClipboardWriteText(value: unknown): string { + if (typeof value !== "string") { + throw new Error("text muss ein String sein"); + } + if (!value.trim()) { + throw new Error("text darf nicht leer sein"); + } + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > CLIPBOARD_WRITE_MAX_BYTES) { + throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`); + } + return value; +} diff --git a/src/main/main.ts b/src/main/main.ts index 07a8d89..eb1641a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -24,6 +24,7 @@ import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { validateRealDebridLoginRequest } from "../shared/preload-api"; import { migrateProductUserDataDirectory } from "./storage"; import { forceDarkNativeTheme } from "./native-theme"; +import { validateClipboardWriteText } from "./clipboard-write"; forceDarkNativeTheme(nativeTheme); @@ -42,8 +43,7 @@ function validatePlainObject(value: unknown, name: string): Record([ "realdebrid", "megadebrid-api", @@ -636,17 +636,11 @@ function registerIpcHandlers(): void { controller.updateSettings({ clipboardWatch: next }); updateClipboardWatcher(); return next; - }); - handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { - const text = validateString(rawText, "text"); - const bytes = Buffer.byteLength(text, "utf8"); - if (!text.trim()) { - throw new Error("text darf nicht leer sein"); - } - if (bytes > CLIPBOARD_WRITE_MAX_BYTES) { - throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`); - } - try { + }); + handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { + const text = validateClipboardWriteText(rawText); + const bytes = Buffer.byteLength(text, "utf8"); + try { clipboard.writeText(text); return true; } catch (error) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2c735a2..af9fb6e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -60,6 +60,7 @@ import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog"; import { Dialog } from "./ui/Dialog"; import { Icon } from "./ui/Icon"; import { Toast } from "./ui/Toast"; +import { LinkAddressesDialog } from "./ui/LinkAddressesDialog"; import { buildCollectorViewModel, type CollectorSourceTab @@ -4438,7 +4439,7 @@ export function App(): ReactElement { const onCopyOnlineBackupKey = async (): Promise => { if (!onlineBackupDialog?.key) return; try { - await navigator.clipboard.writeText(onlineBackupDialog.key); + if (!(await window.rd.writeClipboardText(onlineBackupDialog.key))) throw new Error("clipboard_write_rejected"); showToast("Online-Schlüssel kopiert", 2200); } catch { showToast("Schlüssel konnte nicht kopiert werden", 2600); @@ -4505,13 +4506,13 @@ export function App(): ReactElement { cancelLabel: "Schließen", details: details || undefined, detailsLabel: "Einträge anzeigen" - }); - if (copy && entries.length > 0) { - await navigator.clipboard.writeText(details); - showToast("Fehlerliste kopiert", 2600); - } - } catch (error) { - showToast(`Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); + }); + if (copy && entries.length > 0) { + if (!(await window.rd.writeClipboardText(details))) throw new Error("clipboard_write_rejected"); + showToast("Fehlerliste kopiert", 2600); + } + } catch (error) { + showToast(String(error).includes("clipboard_write_rejected") ? "Kopieren fehlgeschlagen" : `Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); } }; @@ -4605,13 +4606,13 @@ export function App(): ReactElement { } }; - const onCopyRemoteDiagnosticsCode = async (): Promise => { + const onCopyRemoteDiagnosticsCode = async (): Promise => { if (!remoteDiag?.code) { return; - } - try { - await navigator.clipboard.writeText(remoteDiag.code); - showToast("Verbindungscode kopiert", 2200); + } + try { + if (!(await window.rd.writeClipboardText(remoteDiag.code))) throw new Error("clipboard_write_rejected"); + showToast("Verbindungscode kopiert", 2200); } catch { showToast("Kopieren fehlgeschlagen", 2200); } @@ -6635,8 +6636,8 @@ export function App(): ReactElement { type="button" title={`${key.masked}\nMaskierte Kennung kopieren`} onClick={() => { - void navigator.clipboard.writeText(key.masked) - .then(() => showToast("Maskierte Kennung kopiert", 1800)) + void window.rd.writeClipboardText(key.masked) + .then((copied) => copied ? showToast("Maskierte Kennung kopiert", 1800) : showToast("Kopieren fehlgeschlagen", 2200)) .catch(() => showToast("Kopieren fehlgeschlagen", 2200)); }} > @@ -6688,32 +6689,14 @@ export function App(): ReactElement { /> ) : null} {linkPopup ? ( - setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen"> -

{linkPopup.title}

-
- {linkPopup.links.map((link, i) => ( -
- - -
- ))} -
-
- {linkPopup.isPackage && ( - - )} - {linkPopup.isPackage && ( - - )} - -
-
+ setLinkPopup(null)} + onToast={showToast} + title={linkPopup.title} + writeClipboardText={window.rd.writeClipboardText} + /> ) : null} )} diff --git a/src/renderer/ui/LinkAddressesDialog.tsx b/src/renderer/ui/LinkAddressesDialog.tsx new file mode 100644 index 0000000..94c4cb5 --- /dev/null +++ b/src/renderer/ui/LinkAddressesDialog.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from "react"; +import { Dialog } from "./Dialog"; + +export interface LinkAddressesDialogProps { + title: string; + links: Array<{ name: string; url: string }>; + isPackage: boolean; + onClose: () => void; + writeClipboardText: (text: string) => Promise; + onToast: (message: string) => void; +} + +export function LinkAddressesDialog({ + title, + links, + isPackage, + onClose, + writeClipboardText, + onToast +}: LinkAddressesDialogProps): ReactElement { + const copy = async (text: string, successMessage: string): Promise => { + try { + const copied = await writeClipboardText(text); + onToast(copied === true ? successMessage : "Kopieren fehlgeschlagen"); + } catch { + onToast("Kopieren fehlgeschlagen"); + } + }; + + return ( + +

{title}

+
+ {links.map((link, index) => ( +
+ + +
+ ))} +
+
+ {isPackage ? ( + + ) : null} + {isPackage ? ( + + ) : null} + +
+
+ ); +} diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 137744f..c2f4836 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -9,12 +9,14 @@ import { getSnapshotRenderDelay } from "../src/renderer/App"; describe("desktop shell", () => { it("uses keyboard-focusable controls for every copy target", () => { - const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); + const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); + const dialogSource = readFileSync(new URL("../src/renderer/ui/LinkAddressesDialog.tsx", import.meta.url), "utf8"); + const source = `${appSource}\n${dialogSource}`; expect(source).not.toMatch(/]*className="[^"]*link-popup-click/); - expect(source.match(/]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3); + expect(dialogSource.match(/className="link-popup-(?:name|url) link-popup-click"/g)).toHaveLength(2); expect(source).not.toContain("navigator.clipboard.writeText(key.token)"); - expect(source).toContain("navigator.clipboard.writeText(key.masked)"); + expect(source).toContain("window.rd.writeClipboardText(key.masked)"); expect(source).toContain("Maskierte Kennung kopiert"); }); diff --git a/tests/clipboard-write.test.ts b/tests/clipboard-write.test.ts new file mode 100644 index 0000000..9915765 --- /dev/null +++ b/tests/clipboard-write.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { CLIPBOARD_WRITE_MAX_BYTES, validateClipboardWriteText } from "../src/main/clipboard-write"; + +describe("clipboard write validation", () => { + it("accepts complete large link packages up to one MiB", () => { + const text = "x".repeat(CLIPBOARD_WRITE_MAX_BYTES); + expect(validateClipboardWriteText(text)).toBe(text); + }); + + it("rejects empty, non-string and oversized payloads", () => { + expect(() => validateClipboardWriteText(" \n ")).toThrow(/leer/i); + expect(() => validateClipboardWriteText(4)).toThrow(/String/i); + expect(() => validateClipboardWriteText("x".repeat(CLIPBOARD_WRITE_MAX_BYTES + 1))).toThrow(/zu groß/i); + }); +}); diff --git a/tests/link-addresses-dialog.test.tsx b/tests/link-addresses-dialog.test.tsx new file mode 100644 index 0000000..d22f024 --- /dev/null +++ b/tests/link-addresses-dialog.test.tsx @@ -0,0 +1,115 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { LinkAddressesDialog, type LinkAddressesDialogProps } from "../src/renderer/ui/LinkAddressesDialog"; + +function findElements(node: ReactNode, predicate: (element: ReactElement>) => boolean): ReactElement>[] { + if (Array.isArray(node)) return node.flatMap((child) => findElements(child, predicate)); + if (!isValidElement>(node)) return []; + const matches = predicate(node) ? [node] : []; + return [...matches, ...findElements(node.props.children as ReactNode, predicate)]; +} + +function createDialog(overrides: Partial = {}): ReactElement { + return LinkAddressesDialog({ + title: "Testpaket", + links: [ + { name: "Erste Datei.mkv", url: "https://example.com/first" }, + { name: "Zweite Datei.mkv", url: "https://example.com/second" } + ], + isPackage: true, + onClose: vi.fn(), + writeClipboardText: vi.fn(async () => true), + onToast: vi.fn(), + ...overrides + }); +} + +function buttonByText(tree: ReactElement, label: string): ReactElement> { + const button = findElements(tree, (element) => element.type === "button" && element.props.children === label)[0]; + expect(button, `Button ${label} fehlt`).toBeDefined(); + return button; +} + +async function click(button: ReactElement>): Promise { + const onClick = button.props.onClick as (() => void | Promise) | undefined; + expect(onClick).toBeTypeOf("function"); + await onClick?.(); +} + +describe("LinkAddressesDialog", () => { + it("kopiert einzelne Namen und URLs ausschließlich über den sicheren Writer", async () => { + const writeClipboardText = vi.fn(async () => true); + const onToast = vi.fn(); + const tree = createDialog({ writeClipboardText, onToast }); + + const firstName = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Erste Datei.mkv kopieren")[0]; + const firstUrl = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Link kopieren")[0]; + await click(firstName); + await click(firstUrl); + + expect(writeClipboardText).toHaveBeenNthCalledWith(1, "Erste Datei.mkv"); + expect(writeClipboardText).toHaveBeenNthCalledWith(2, "https://example.com/first"); + expect(onToast).toHaveBeenNthCalledWith(1, "Name kopiert"); + expect(onToast).toHaveBeenNthCalledWith(2, "Link kopiert"); + }); + + it("meldet Erfolg nur bei true und behandelt false sowie Ablehnungen als Fehler", async () => { + const writeClipboardText = vi.fn() + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error("clipboard unavailable")); + const onToast = vi.fn(); + const tree = createDialog({ writeClipboardText, onToast }); + + await click(buttonByText(tree, "Alle Namen kopieren")); + await click(buttonByText(tree, "Alle Links kopieren")); + + expect(onToast).toHaveBeenNthCalledWith(1, "Kopieren fehlgeschlagen"); + expect(onToast).toHaveBeenNthCalledWith(2, "Kopieren fehlgeschlagen"); + expect(onToast).not.toHaveBeenCalledWith("Alle Namen kopiert"); + expect(onToast).not.toHaveBeenCalledWith("Alle Links kopiert"); + }); + + it("übergibt große Pakettexte ohne Kürzung oder Normalisierung", async () => { + const longName = `Groß-${"n".repeat(300_000)}`; + const longUrl = `https://example.com/${"u".repeat(300_000)}`; + const writeClipboardText = vi.fn(async () => true); + const onToast = vi.fn(); + const tree = createDialog({ + links: [ + { name: longName, url: longUrl }, + { name: " Zeilenende ", url: "https://example.com/trailing " } + ], + writeClipboardText, + onToast + }); + + await click(buttonByText(tree, "Alle Namen kopieren")); + await click(buttonByText(tree, "Alle Links kopieren")); + + expect(writeClipboardText).toHaveBeenNthCalledWith(1, `${longName}\n Zeilenende `); + expect(writeClipboardText).toHaveBeenNthCalledWith(2, `${longUrl}\nhttps://example.com/trailing `); + expect(onToast).toHaveBeenNthCalledWith(1, "Alle Namen kopiert"); + expect(onToast).toHaveBeenNthCalledWith(2, "Alle Links kopiert"); + }); + + it("behält Dialogdesign, Paketaktionen und Schließen-Verhalten bei", async () => { + const onClose = vi.fn(); + const packageTree = createDialog({ onClose }); + const singleTree = createDialog({ isPackage: false }); + const dialog = findElements(packageTree, (element) => typeof element.type === "function")[0]; + + expect(dialog.props.className).toBe("link-popup"); + expect(dialog.props.size).toBe("wide"); + expect(dialog.props.title).toBe("Linkadressen anzeigen"); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-row")).toHaveLength(2); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-name link-popup-click")).toHaveLength(2); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-url link-popup-click")).toHaveLength(2); + expect(buttonByText(packageTree, "Alle Namen kopieren")).toBeDefined(); + expect(buttonByText(packageTree, "Alle Links kopieren")).toBeDefined(); + expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Namen kopieren")).toHaveLength(0); + expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Links kopieren")).toHaveLength(0); + + await click(buttonByText(packageTree, "Schließen")); + expect(onClose).toHaveBeenCalledOnce(); + }); +});