Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions scripts/localFileLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
14 changes: 13 additions & 1 deletion src/lib/utils/localFileLinks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolveExportImagePath } from './exportHtml.js';
import { isAssetUrl, resolveExportImagePath } from './exportHtml.js';

const absoluteFilePathPattern = /^(?:[a-zA-Z]:[\\/]|\/|\\\\)/;

Expand Down Expand Up @@ -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;
Expand Down
Loading