From 0130582e4c61c928d0263fd08026c2cba1317838 Mon Sep 17 00:00:00 2001 From: PathGao Date: Mon, 3 Aug 2026 13:00:53 +0800 Subject: [PATCH] fix(links): stop a document link from opening a local file through an asset URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveLocalFileLinkPath` delegates to `resolveExportImagePath`, which accepts `asset://localhost/…` and `http://asset.localhost/…` - the shape a local image's `src` takes inside the webview, which the exporter has to turn back into a disk path to inline the bytes. A link is the opposite situation: the href is the author's own text. So `[report](http://asset.localhost/Users/me/.ssh/id_rsa)` reads as a remote address in the link text and the status bar, resolves to `/Users/me/.ssh/id_rsa`, and is handed to the OS default handler - now reachable, since the opener path scope was opened in #403. Reusing the image resolver was right; inheriting that one clause of it was not. The link resolver rejects asset URLs before delegating. Co-Authored-By: Claude Opus 5 --- scripts/localFileLinks.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/utils/localFileLinks.ts | 14 +++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/scripts/localFileLinks.test.ts b/scripts/localFileLinks.test.ts index 652dead..8afd677 100644 --- a/scripts/localFileLinks.test.ts +++ b/scripts/localFileLinks.test.ts @@ -146,3 +146,33 @@ test('neither OS call can leave an unhandled rejection behind', () => { } assert.equal(handler.match(/addToast\(`Failed to open/g)?.length, 2, 'both failures are reported'); }); + +test('an asset URL is never treated as a link to a local file', () => { + // `resolveExportImagePath` accepts `asset://localhost/…` and + // `http://asset.localhost/…` on purpose: that is the shape a local image's + // `src` takes inside the webview, and the exporter has to turn it back into + // a disk path to inline the bytes. A *link* is the opposite situation — the + // href is the author's own text — so inheriting that clause let a document + // write a link that reads as a remote address in the link text and the + // status bar while resolving to a local path and going to the OS default + // handler. + for (const href of [ + 'http://asset.localhost/etc/passwd', + 'https://asset.localhost/Users/me/.ssh/id_rsa', + 'http://asset.localhost/C:/Windows/System32/drivers/etc/hosts', + 'asset://localhost/etc/hosts', + 'ASSET://LOCALHOST/etc/hosts', + 'HTTP://ASSET.LOCALHOST/etc/passwd', + ]) { + assert.equal(resolveLocalFileLinkPath(href, CURRENT), null, href); + } + + // The host is matched as a whole label, so a lookalike is a plain remote + // address and was never in scope — asserted so a future loosening of the + // pattern cannot pass this file. + assert.equal(resolveLocalFileLinkPath('http://asset.localhost.evil.test/etc/passwd', CURRENT), null); + + // Ordinary links keep working: this guard must not swallow them. + assert.equal(resolveLocalFileLinkPath('./data.csv', CURRENT), '/notes/data.csv'); + assert.equal(resolveLocalFileLinkPath('https://example.com/page', CURRENT), null); +}); diff --git a/src/lib/utils/localFileLinks.ts b/src/lib/utils/localFileLinks.ts index db0b7c1..d583697 100644 --- a/src/lib/utils/localFileLinks.ts +++ b/src/lib/utils/localFileLinks.ts @@ -1,4 +1,4 @@ -import { resolveExportImagePath } from './exportHtml.js'; +import { isAssetUrl, resolveExportImagePath } from './exportHtml.js'; const absoluteFilePathPattern = /^(?:[a-zA-Z]:[\\/]|\/|\\\\)/; @@ -36,6 +36,18 @@ export function resolveLocalFileLinkPath(rawHref: string, currentFile: string): const trimmed = rawHref.trim(); if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null; + // An asset URL is never a link the author wrote. `resolveExportImagePath` + // accepts `asset://localhost/…` and `http://asset.localhost/…` because that + // is the shape a local image's `src` takes inside the webview, and the + // exporter has to turn those back into disk paths to inline them. A link is + // the opposite situation: the href is the author's text, and accepting the + // asset form there means `[report](http://asset.localhost/Users/me/.ssh/id_rsa)` + // reads as a remote address everywhere the user can see it — the link text, + // the status bar — while resolving to a local path that goes straight to the + // OS default handler. Reusing the image resolver is right; inheriting that + // one clause of it is not. + if (isAssetUrl(trimmed)) return null; + const resolved = resolveExportImagePath(trimmed, currentFile); if (!resolved) return null; if (!currentFile && !absoluteFilePathPattern.test(resolved)) return null;