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:
@@ -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;
|
||||
}
|
||||
+7
-13
@@ -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<string, unkno
|
||||
}
|
||||
|
||||
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>([
|
||||
"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) {
|
||||
|
||||
+24
-41
@@ -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<void> => {
|
||||
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<void> => {
|
||||
const onCopyRemoteDiagnosticsCode = async (): Promise<void> => {
|
||||
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 ? (
|
||||
<Dialog actions={null} className="link-popup" onClose={() => setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen">
|
||||
<p>{linkPopup.title}</p>
|
||||
<div className="link-popup-list">
|
||||
{linkPopup.links.map((link, i) => (
|
||||
<div key={i} className="link-popup-row">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<LinkAddressesDialog
|
||||
isPackage={linkPopup.isPackage}
|
||||
links={linkPopup.links}
|
||||
onClose={() => setLinkPopup(null)}
|
||||
onToast={showToast}
|
||||
title={linkPopup.title}
|
||||
writeClipboardText={window.rd.writeClipboardText}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(/<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).toContain("navigator.clipboard.writeText(key.masked)");
|
||||
expect(source).toContain("window.rd.writeClipboardText(key.masked)");
|
||||
expect(source).toContain("Maskierte Kennung kopiert");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user