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.
This commit is contained in:
Sucukdeluxe
2026-08-24 00:19:00 +02:00
parent 581736e02b
commit 20be2a9a03
7 changed files with 253 additions and 57 deletions
+15
View File
@@ -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;
}
+2 -8
View File
@@ -24,6 +24,7 @@ import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { validateRealDebridLoginRequest } from "../shared/preload-api"; import { validateRealDebridLoginRequest } from "../shared/preload-api";
import { migrateProductUserDataDirectory } from "./storage"; import { migrateProductUserDataDirectory } from "./storage";
import { forceDarkNativeTheme } from "./native-theme"; import { forceDarkNativeTheme } from "./native-theme";
import { validateClipboardWriteText } from "./clipboard-write";
forceDarkNativeTheme(nativeTheme); forceDarkNativeTheme(nativeTheme);
@@ -42,7 +43,6 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
} }
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024; const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
const CLIPBOARD_WRITE_MAX_BYTES = 4096;
const RENAME_PACKAGE_MAX_CHARS = 240; const RENAME_PACKAGE_MAX_CHARS = 240;
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([ const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
"realdebrid", "realdebrid",
@@ -638,14 +638,8 @@ function registerIpcHandlers(): void {
return next; return next;
}); });
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => {
const text = validateString(rawText, "text"); const text = validateClipboardWriteText(rawText);
const bytes = Buffer.byteLength(text, "utf8"); 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 { try {
clipboard.writeText(text); clipboard.writeText(text);
return true; return true;
+15 -32
View File
@@ -60,6 +60,7 @@ import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog";
import { Dialog } from "./ui/Dialog"; import { Dialog } from "./ui/Dialog";
import { Icon } from "./ui/Icon"; import { Icon } from "./ui/Icon";
import { Toast } from "./ui/Toast"; import { Toast } from "./ui/Toast";
import { LinkAddressesDialog } from "./ui/LinkAddressesDialog";
import { import {
buildCollectorViewModel, buildCollectorViewModel,
type CollectorSourceTab type CollectorSourceTab
@@ -4438,7 +4439,7 @@ export function App(): ReactElement {
const onCopyOnlineBackupKey = async (): Promise<void> => { const onCopyOnlineBackupKey = async (): Promise<void> => {
if (!onlineBackupDialog?.key) return; if (!onlineBackupDialog?.key) return;
try { 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); showToast("Online-Schlüssel kopiert", 2200);
} catch { } catch {
showToast("Schlüssel konnte nicht kopiert werden", 2600); showToast("Schlüssel konnte nicht kopiert werden", 2600);
@@ -4507,11 +4508,11 @@ export function App(): ReactElement {
detailsLabel: "Einträge anzeigen" detailsLabel: "Einträge anzeigen"
}); });
if (copy && entries.length > 0) { if (copy && entries.length > 0) {
await navigator.clipboard.writeText(details); if (!(await window.rd.writeClipboardText(details))) throw new Error("clipboard_write_rejected");
showToast("Fehlerliste kopiert", 2600); showToast("Fehlerliste kopiert", 2600);
} }
} catch (error) { } catch (error) {
showToast(`Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); showToast(String(error).includes("clipboard_write_rejected") ? "Kopieren fehlgeschlagen" : `Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000);
} }
}; };
@@ -4610,7 +4611,7 @@ export function App(): ReactElement {
return; return;
} }
try { try {
await navigator.clipboard.writeText(remoteDiag.code); if (!(await window.rd.writeClipboardText(remoteDiag.code))) throw new Error("clipboard_write_rejected");
showToast("Verbindungscode kopiert", 2200); showToast("Verbindungscode kopiert", 2200);
} catch { } catch {
showToast("Kopieren fehlgeschlagen", 2200); showToast("Kopieren fehlgeschlagen", 2200);
@@ -6635,8 +6636,8 @@ export function App(): ReactElement {
type="button" type="button"
title={`${key.masked}\nMaskierte Kennung kopieren`} title={`${key.masked}\nMaskierte Kennung kopieren`}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(key.masked) void window.rd.writeClipboardText(key.masked)
.then(() => showToast("Maskierte Kennung kopiert", 1800)) .then((copied) => copied ? showToast("Maskierte Kennung kopiert", 1800) : showToast("Kopieren fehlgeschlagen", 2200))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200)); .catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}} }}
> >
@@ -6688,32 +6689,14 @@ export function App(): ReactElement {
/> />
) : null} ) : null}
{linkPopup ? ( {linkPopup ? (
<Dialog actions={null} className="link-popup" onClose={() => setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen"> <LinkAddressesDialog
<p>{linkPopup.title}</p> isPackage={linkPopup.isPackage}
<div className="link-popup-list"> links={linkPopup.links}
{linkPopup.links.map((link, i) => ( onClose={() => setLinkPopup(null)}
<div key={i} className="link-popup-row"> onToast={showToast}
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button> title={linkPopup.title}
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button> writeClipboardText={window.rd.writeClipboardText}
</div> />
))}
</div>
<div className="modal-actions">
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.name).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Namen kopieren</button>
)}
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.url).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Links kopieren</button>
)}
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
</div>
</Dialog>
) : null} ) : null}
</> </>
)} )}
+72
View File
@@ -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<boolean>;
onToast: (message: string) => void;
}
export function LinkAddressesDialog({
title,
links,
isPackage,
onClose,
writeClipboardText,
onToast
}: LinkAddressesDialogProps): ReactElement {
const copy = async (text: string, successMessage: string): Promise<void> => {
try {
const copied = await writeClipboardText(text);
onToast(copied === true ? successMessage : "Kopieren fehlgeschlagen");
} catch {
onToast("Kopieren fehlgeschlagen");
}
};
return (
<Dialog actions={null} className="link-popup" onClose={onClose} open size="wide" title="Linkadressen anzeigen">
<p>{title}</p>
<div className="link-popup-list">
{links.map((link, index) => (
<div key={index} className="link-popup-row">
<button
aria-label={`${link.name} kopieren`}
className="link-popup-name link-popup-click"
onClick={() => copy(link.name, "Name kopiert")}
title={`${link.name}\nKlicken zum Kopieren`}
type="button"
>
{link.name}
</button>
<button
aria-label="Link kopieren"
className="link-popup-url link-popup-click"
onClick={() => copy(link.url, "Link kopiert")}
title={`${link.url}\nKlicken zum Kopieren`}
type="button"
>
{link.url}
</button>
</div>
))}
</div>
<div className="modal-actions">
{isPackage ? (
<button className="btn" onClick={() => copy(links.map((link) => link.name).join("\n"), "Alle Namen kopiert")} type="button">
Alle Namen kopieren
</button>
) : null}
{isPackage ? (
<button className="btn" onClick={() => copy(links.map((link) => link.url).join("\n"), "Alle Links kopiert")} type="button">
Alle Links kopieren
</button>
) : null}
<button className="btn" onClick={onClose} type="button">Schließen</button>
</div>
</Dialog>
);
}
+5 -3
View File
@@ -9,12 +9,14 @@ import { getSnapshotRenderDelay } from "../src/renderer/App";
describe("desktop shell", () => { describe("desktop shell", () => {
it("uses keyboard-focusable controls for every copy target", () => { 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(/<span[^>]*className="[^"]*link-popup-click/); expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
expect(source.match(/<button[^>]*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).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"); expect(source).toContain("Maskierte Kennung kopiert");
}); });
+15
View File
@@ -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);
});
});
+115
View File
@@ -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<Record<string, unknown>>) => boolean): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) return node.flatMap((child) => findElements(child, predicate));
if (!isValidElement<Record<string, unknown>>(node)) return [];
const matches = predicate(node) ? [node] : [];
return [...matches, ...findElements(node.props.children as ReactNode, predicate)];
}
function createDialog(overrides: Partial<LinkAddressesDialogProps> = {}): 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<Record<string, unknown>> {
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<Record<string, unknown>>): Promise<void> {
const onClick = button.props.onClick as (() => void | Promise<void>) | 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();
});
});