Skip to content

Commit 6143610

Browse files
Frooodlesaul1310
andauthored
fix(frontend): preserve pdf link targets in desktop viewer (Stirling-Tools#7235)
Same PR as Stirling-Tools#6396, just with the conflicts resolved and some fixes on top. Original commit by @saul1310 is preserved as-is; everything else is a follow-up commit. Refs Stirling-Tools#6272 ## Conflicts Stirling-Tools#6396 was written before the frontend was restructured, so all four files it touched moved (`frontend/src/**` -> `frontend/editor/src/**`) and `LinkLayer.tsx` had drifted. Cherry-picked with rename detection and re-resolved against current `main`. ## Fixes on top - **Reuse the existing platform seam instead of adding a second one.** `main` already has `@app/platform/*` seams with per-flavour implementations; Stirling-Tools#6396 added a parallel `@app/utils/openExternalUrl` core+desktop pair that re-implemented the Tauri shell call already in `desktop/platform/openExternal.ts`. Split into a pure sanitiser (`@app/utils/externalUrl`) and a platform seam (`@app/platform/openExternalTab`), with the desktop impl delegating to the existing `openExternal`. - **Kept PDF links off the `openExternal` seam.** That seam is for leave-and-return redirects (Stripe) and its saas impl is `window.location.assign` - routing PDF links through it would navigate the whole app away from the user's document. `openExternalTab` always opens alongside the app; desktop shadows it to escape the webview. - **Fixed the same defect in two sibling call sites** that Stirling-Tools#6396 didn't cover: `BookmarkSidebar` (bookmark URI / LaunchAppOrOpenFile actions) and `useAnnotationMenuHandlers` (annotation menu "go to link"). Both called `window.open` on an unsanitised PDF-supplied URI, so on desktop they trapped the link in the webview exactly like the viewer did. - **Dropped the unguarded fallback.** The old code fell back to `window.open(uri)` when `new URL()` threw, so an unparseable URI bypassed the allowlist entirely. It is now blocked. - Empty/whitespace URIs are blocked rather than silently resolving to the app's own page via the base URL. - Tests: sanitiser cases (casing, leading whitespace, `data:`, `vbscript:`, unparseable), a core seam test asserting new-tab-not-navigate, and a desktop seam regression test asserting the URL goes to the OS rather than `window.open`. - **`openExternalTab` now re-validates its own input.** Every caller sanitises first, so nothing reached it unvalidated - but it is the sink that hands a URL to `window.open` (executes `javascript:` in our origin) or to an OS handler on desktop, and its safety shouldn't depend on callers remembering. Both impls fail closed, with tests that call them directly with `javascript:`/`data:`/`file:`/`ftp:`. ## Unrelated fix included (flagged deliberately) The last commit fixes `frontend/editor/vitest.config.ts`: `testTimeout: 10000` was set on the root `test` block, but tests all run under `projects`, which do not inherit it - so the whole suite has silently been running at vitest's 5s default. This is not cosmetic. It made `task check` fail intermittently on unrelated portal specs (`demoData`, `ConnectionModal`); the ConsignO test takes 2966ms with only the portal project running, i.e. 59% of a budget it was never meant to have, so any CPU contention tips it over. Proven with an identical 6.5s probe test: times out at 5000ms on the old config, passes at 6512ms on the fixed one. Happy to split this into its own PR if preferred - it is here because the gate could not be trusted without it. ## Validation Typecheck passes for all 7 build flavours (core, proprietary, saas, desktop, cloud, prototypes, portal); ESLint, Prettier, dpdm and the full 1662-test vitest suite pass. Driven live against the dev server + backend with a PDF carrying five URI annotations (https, `javascript:`, mailto, `file:`, relative). 14/14 behavioural checks pass on this branch; 5 of them fail on `main`: | check | main | this PR | | --- | --- | --- | | safe https link exposes real href (copy-link) | `href="#"` | `https://example.com/safe-link?a=1` | | link opens in new tab / tabnabbing-proof | no `target`/`rel` | `_blank` + `noopener noreferrer` | | mailto link exposes real href | `href="#"` | `mailto:test@example.com` | | relative URI resolved against app origin | `href="#"` | resolved | | `javascript:` / `file:` never reach href | blocked | blocked | | clicking blocked link doesn't execute or navigate | ok | ok | | clicking safe link opens new tab at source URL | - | ok, app not navigated away | --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally --------- Co-authored-by: Saul <saulifshin.cs@gmail.com>
1 parent bff1ea9 commit 6143610

10 files changed

Lines changed: 270 additions & 19 deletions

File tree

frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { useFileContext } from "@app/contexts/FileContext";
1717
import { isStirlingFile, type FileId } from "@app/types/fileContext";
1818
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
1919
import apiClient from "@app/services/apiClient";
20+
import { openExternalTab } from "@app/platform/openExternalTab";
21+
import { getExternalHref } from "@app/utils/externalUrl";
2022
import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models";
2123
import { useTranslation } from "react-i18next";
2224
import BookmarksIcon from "@mui/icons-material/BookmarksRounded";
@@ -74,6 +76,17 @@ const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => {
7476
return null;
7577
};
7678

79+
// Bookmark targets are PDF-supplied, so sanitise before opening. Local paths
80+
// from LaunchAppOrOpenFile fail the allowlist - a browser blocks them anyway.
81+
const openBookmarkTarget = (rawUrl: string): void => {
82+
const href = getExternalHref(rawUrl);
83+
if (!href) {
84+
console.warn("[BookmarkSidebar] Blocked unsafe URL:", rawUrl);
85+
return;
86+
}
87+
void openExternalTab(href);
88+
};
89+
7790
export const BookmarkSidebar = ({
7891
visible,
7992
thumbnailVisible,
@@ -515,12 +528,12 @@ export const BookmarkSidebar = ({
515528
const action = target.action;
516529
if (action.type === PdfActionType.URI && action.uri) {
517530
event.preventDefault();
518-
window.open(action.uri, "_blank", "noopener");
531+
openBookmarkTarget(action.uri);
519532
return;
520533
}
521534
if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) {
522535
event.preventDefault();
523-
window.open(action.path, "_blank", "noopener");
536+
openBookmarkTarget(action.path);
524537
return;
525538
}
526539
}

frontend/editor/src/core/components/viewer/LinkLayer.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
import { Z_INDEX_VIEWER_FLOATING_MENU } from "@app/styles/zIndex";
2020
import { Button } from "@app/ui/Button";
2121
import { ActionIcon } from "@app/ui/ActionIcon";
22+
import { openExternalTab } from "@app/platform/openExternalTab";
23+
import { getExternalHref } from "@app/utils/externalUrl";
24+
2225
// ---------------------------------------------------------------------------
2326
// Inline SVG icons (thin-stroke, modern)
2427
// ---------------------------------------------------------------------------
@@ -401,19 +404,11 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
401404
behavior: "smooth",
402405
});
403406
} else if (action.type === PdfActionType.URI) {
404-
const uri = action.uri;
405-
try {
406-
const url = new URL(uri, window.location.href);
407-
if (["http:", "https:", "mailto:"].includes(url.protocol)) {
408-
window.open(uri, "_blank", "noopener,noreferrer");
409-
} else {
410-
console.warn(
411-
"[LinkLayer] Blocked unsafe URL protocol:",
412-
url.protocol,
413-
);
414-
}
415-
} catch {
416-
window.open(uri, "_blank", "noopener,noreferrer");
407+
const href = getExternalHref(action.uri);
408+
if (href) {
409+
void openExternalTab(href);
410+
} else {
411+
console.warn("[LinkLayer] Blocked unsafe URL:", action.uri);
417412
}
418413
}
419414
}
@@ -513,6 +508,11 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
513508
const top = annotationLink.rect.origin.y * scale;
514509
const width = annotationLink.rect.size.width * scale;
515510
const height = annotationLink.rect.size.height * scale;
511+
const externalHref =
512+
annotationLink.target?.type === "action" &&
513+
annotationLink.target.action.type === PdfActionType.URI
514+
? getExternalHref(annotationLink.target.action.uri)
515+
: null;
516516

517517
return (
518518
<a
@@ -524,7 +524,9 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
524524
linkElementRefs.current.delete(annotationLink.id);
525525
}
526526
}}
527-
href="#"
527+
href={externalHref ?? "#"}
528+
target={externalHref ? "_blank" : undefined}
529+
rel={externalHref ? "noopener noreferrer" : undefined}
528530
onClick={(e) => {
529531
e.preventDefault();
530532
e.stopPropagation();

frontend/editor/src/core/components/viewer/useAnnotationMenuHandlers.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
AnnotationPatch,
1515
} from "@app/components/viewer/viewerTypes";
1616
import type { ScrollActions } from "@app/contexts/viewer/viewerActions";
17+
import { openExternalTab } from "@app/platform/openExternalTab";
18+
import { getExternalHref } from "@app/utils/externalUrl";
1719

1820
export type AnnotationType =
1921
| "textMarkup"
@@ -370,7 +372,15 @@ export function useAnnotationMenuHandlers({
370372
const onGoToLink = useCallback(() => {
371373
if (!firstLinkTarget) return;
372374
if (firstLinkTarget.type === "uri") {
373-
window.open(firstLinkTarget.uri, "_blank", "noopener,noreferrer");
375+
const href = getExternalHref(firstLinkTarget.uri);
376+
if (href) {
377+
void openExternalTab(href);
378+
} else {
379+
console.warn(
380+
"[useAnnotationMenuHandlers] Blocked unsafe URL:",
381+
firstLinkTarget.uri,
382+
);
383+
}
374384
} else {
375385
scrollActions.scrollToPage(firstLinkTarget.pageIndex + 1);
376386
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { afterEach, describe, expect, test, vi } from "vitest";
2+
import { openExternalTab } from "@app/platform/openExternalTab";
3+
import { expectConsole } from "@app/tests/failOnConsole";
4+
5+
describe("openExternalTab (core/web)", () => {
6+
afterEach(() => {
7+
vi.restoreAllMocks();
8+
});
9+
10+
// Opening in a new tab is the point of this seam: @app/platform/openExternal
11+
// navigates the current tab on saas, which would tear the user out of the PDF.
12+
test("opens alongside the app rather than navigating it away", async () => {
13+
const openSpy = vi
14+
.spyOn(window, "open")
15+
.mockImplementation(() => null as Window | null);
16+
const originalHref = window.location.href;
17+
18+
await openExternalTab("https://example.com/");
19+
20+
expect(openSpy).toHaveBeenCalledWith(
21+
"https://example.com/",
22+
"_blank",
23+
"noopener,noreferrer",
24+
);
25+
expect(window.location.href).toBe(originalHref);
26+
});
27+
28+
// The seam is the sink, so it must not depend on callers having sanitised:
29+
// window.open on a javascript: URL would execute it in our own origin.
30+
test.each([
31+
"javascript:alert(1)",
32+
" javascript:alert(1)",
33+
"data:text/html,<script>alert(1)</script>",
34+
"file:///etc/passwd",
35+
])("refuses to open %s even if a caller skips sanitising", async (url) => {
36+
const openSpy = vi
37+
.spyOn(window, "open")
38+
.mockImplementation(() => null as Window | null);
39+
expectConsole.warn(/Refused to open unsafe URL/);
40+
41+
await openExternalTab(url);
42+
43+
expect(openSpy).not.toHaveBeenCalled();
44+
});
45+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* core/web implementation of the @app/platform/openExternalTab seam.
3+
*
4+
* Distinct from @app/platform/openExternal: that seam is for "leave and return"
5+
* redirects (Stripe checkout), so its saas impl navigates the CURRENT tab. A PDF
6+
* link must never do that — it would tear the user out of their document — so
7+
* this seam always opens alongside the app. Desktop shadows it to escape the
8+
* Tauri webview; saas/proprietary fall through to this window.open.
9+
*
10+
* Callers are expected to sanitise, but this is the sink that actually hands the
11+
* URL to the browser, so it re-checks rather than trusting them: window.open on
12+
* a `javascript:` URL executes it in our own origin.
13+
*/
14+
import { getExternalHref } from "@app/utils/externalUrl";
15+
16+
export type OpenExternalTab = (url: string) => Promise<void>;
17+
18+
export const openExternalTab: OpenExternalTab = async (
19+
url: string,
20+
): Promise<void> => {
21+
const safeHref = getExternalHref(url);
22+
if (!safeHref) {
23+
console.warn("[openExternalTab] Refused to open unsafe URL:", url);
24+
return;
25+
}
26+
window.open(safeHref, "_blank", "noopener,noreferrer");
27+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, test } from "vitest";
2+
import { getExternalHref, toSafeExternalUrl } from "@app/utils/externalUrl";
3+
4+
describe("externalUrl", () => {
5+
test("accepts http/https/mailto URLs", () => {
6+
expect(toSafeExternalUrl("https://example.com/test")?.href).toBe(
7+
"https://example.com/test",
8+
);
9+
expect(toSafeExternalUrl("http://example.com/test")?.href).toBe(
10+
"http://example.com/test",
11+
);
12+
expect(toSafeExternalUrl("mailto:test@example.com")?.href).toBe(
13+
"mailto:test@example.com",
14+
);
15+
});
16+
17+
test("rejects unsafe protocols", () => {
18+
expect(toSafeExternalUrl("javascript:alert(1)")).toBeNull();
19+
expect(toSafeExternalUrl("file:///etc/passwd")).toBeNull();
20+
expect(toSafeExternalUrl("ftp://example.com")).toBeNull();
21+
expect(
22+
toSafeExternalUrl("data:text/html,<script>alert(1)</script>"),
23+
).toBeNull();
24+
expect(toSafeExternalUrl("vbscript:msgbox(1)")).toBeNull();
25+
});
26+
27+
test("rejects unparseable input instead of opening it blind", () => {
28+
expect(toSafeExternalUrl("")).toBeNull();
29+
expect(toSafeExternalUrl("http://[")).toBeNull();
30+
});
31+
32+
test("is not fooled by casing or leading whitespace", () => {
33+
expect(toSafeExternalUrl("JavaScript:alert(1)")).toBeNull();
34+
expect(toSafeExternalUrl(" javascript:alert(1)")).toBeNull();
35+
expect(toSafeExternalUrl("HTTPS://example.com")?.protocol).toBe("https:");
36+
});
37+
38+
test("normalizes relative URLs against current origin", () => {
39+
expect(getExternalHref("/docs/help")?.endsWith("/docs/help")).toBe(true);
40+
});
41+
42+
test("getExternalHref returns null for blocked URLs", () => {
43+
expect(getExternalHref("javascript:alert(1)")).toBeNull();
44+
});
45+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Sanitisation for URLs that come out of a PDF (link annotations, bookmark
3+
* actions). PDF-supplied URIs are untrusted input, so everything that opens one
4+
* funnels through here first and drops anything outside the allowlist.
5+
*
6+
* Pure helpers only — opening the URL is a platform concern and lives behind
7+
* the @app/platform/openExternalTab seam.
8+
*/
9+
const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
10+
11+
function getExternalUrlBase(): string | undefined {
12+
// Relative URIs resolve against the app's own location, matching how the
13+
// viewer has always treated them. No DOM => absolute URLs only.
14+
return typeof window !== "undefined" ? window.location?.href : undefined;
15+
}
16+
17+
/** Parses `rawUrl` and returns it only if its protocol is on the allowlist. */
18+
export function toSafeExternalUrl(rawUrl: string): URL | null {
19+
// An empty URI would otherwise resolve to the app's own page via the base.
20+
if (!rawUrl?.trim()) return null;
21+
try {
22+
const parsed = new URL(rawUrl, getExternalUrlBase());
23+
return ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol) ? parsed : null;
24+
} catch {
25+
return null;
26+
}
27+
}
28+
29+
/** Normalised href for a safe external URL, or null if it is not safe to open. */
30+
export function getExternalHref(rawUrl: string): string | null {
31+
return toSafeExternalUrl(rawUrl)?.href ?? null;
32+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, describe, expect, test, vi } from "vitest";
2+
3+
const shellOpenMock = vi.fn();
4+
5+
vi.mock("@tauri-apps/plugin-shell", () => ({
6+
open: (url: string) => shellOpenMock(url),
7+
}));
8+
9+
import { openExternalTab } from "@app/platform/openExternalTab";
10+
import { expectConsole } from "@app/tests/failOnConsole";
11+
12+
describe("openExternalTab (desktop/Tauri)", () => {
13+
afterEach(() => {
14+
vi.restoreAllMocks();
15+
shellOpenMock.mockReset();
16+
});
17+
18+
// Regression for #6272: window.open traps the link inside the Tauri webview,
19+
// so a PDF link opened a blank in-app window instead of the user's browser.
20+
test("hands the URL to the OS instead of the webview", async () => {
21+
const openSpy = vi
22+
.spyOn(window, "open")
23+
.mockImplementation(() => null as Window | null);
24+
25+
await openExternalTab("https://example.com/");
26+
27+
expect(shellOpenMock).toHaveBeenCalledWith("https://example.com/");
28+
expect(openSpy).not.toHaveBeenCalled();
29+
});
30+
31+
// Worse than the web case: an unvalidated scheme here reaches an OS handler
32+
// rather than staying inside a browser.
33+
test.each(["javascript:alert(1)", "file:///etc/passwd", "ftp://example.com"])(
34+
"refuses to hand %s to the OS",
35+
async (url) => {
36+
expectConsole.warn(/Refused to open unsafe URL/);
37+
38+
await openExternalTab(url);
39+
40+
expect(shellOpenMock).not.toHaveBeenCalled();
41+
},
42+
);
43+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* desktop (Tauri) implementation of the @app/platform/openExternalTab seam.
3+
*
4+
* window.open would trap the URL inside our own webview, so hand it to the OS.
5+
* Delegates to the openExternal seam rather than calling the Tauri shell plugin
6+
* again — on desktop "new tab" and "system browser" are the same action.
7+
*
8+
* Re-checks the URL for the same reason the core impl does, and more so: here it
9+
* reaches an OS handler, so an unvalidated scheme is not confined to a browser.
10+
*/
11+
import { openExternal } from "@app/platform/openExternal";
12+
import type { OpenExternalTab } from "@core/platform/openExternalTab";
13+
import { getExternalHref } from "@core/utils/externalUrl";
14+
15+
export const openExternalTab: OpenExternalTab = async (
16+
url: string,
17+
): Promise<void> => {
18+
const safeHref = getExternalHref(url);
19+
if (!safeHref) {
20+
console.warn("[openExternalTab] Refused to open unsafe URL:", url);
21+
return;
22+
}
23+
await openExternal(safeHref);
24+
};

0 commit comments

Comments
 (0)