Do not load images from other sites in the markdown preview

An image from another site (http:, https:, or protocol-relative //) loaded as
soon as the preview opened, telling that site who was reading the file and
when.  Such an image now becomes a link labeled with its alt text, so the
reader chooses whether to follow it.  Inside a link (a README badge) it becomes
the outer link's text, since links cannot nest.  Media and embedded data:
images still load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-05 12:15:52 -06:00
parent 61e4807e42
commit f1872a4d79
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 86 additions and 8 deletions

View file

@ -45,22 +45,59 @@ function rewriteURL(token, attr, fileURL) {
}
}
function openInNewTab(token) {
const href = token.attrGet('href');
// Following a link would close the preview; a fragment stays within the document.
if (href && !href.startsWith('#')) {
token.attrSet('target', '_blank');
token.attrSet('rel', 'noopener noreferrer');
}
}
/** An image the preview may load: one served by this WROLPi, or embedded in the file. */
function isLocalImage(src) {
return !src || src.startsWith('data:') || (src.startsWith('/') && !src.startsWith('//'));
}
/**
* Replace an image from another site with a link to it. Loading it would tell that site who is
* reading the file and when (a tracking pixel), so the reader chooses whether to follow it.
*
* Inside a link (a README badge) the image becomes the outer link's text; links cannot nest.
*/
function replaceRemoteImage(state, image, insideLink) {
const src = image.attrGet('src');
const text = new state.Token('text', '', 0);
text.content = image.content || src;
if (insideLink) {
return [text];
}
const linkOpen = new state.Token('link_open', 'a', 1);
linkOpen.attrSet('href', src);
openInNewTab(linkOpen);
return [linkOpen, text, new state.Token('link_close', 'a', -1)];
}
markdown.core.ruler.push('wrolpi_media_urls', (state) => {
const {fileURL} = state.env;
for (const token of state.tokens) {
for (const child of token.children || []) {
if (!token.children) {
continue;
}
let insideLink = false;
token.children = token.children.flatMap(child => {
if (child.type === 'image') {
rewriteURL(child, 'src', fileURL);
return isLocalImage(child.attrGet('src')) ? [child] : replaceRemoteImage(state, child, insideLink);
} else if (child.type === 'link_open') {
insideLink = true;
rewriteURL(child, 'href', fileURL);
const href = child.attrGet('href');
// Following a link would close the preview; a fragment stays within the document.
if (href && !href.startsWith('#')) {
child.attrSet('target', '_blank');
child.attrSet('rel', 'noopener noreferrer');
}
openInNewTab(child);
} else if (child.type === 'link_close') {
insideLink = false;
}
}
return [child];
});
}
});

View file

@ -74,6 +74,47 @@ describe('renderMarkdown', () => {
expect(div.querySelector('img').hasAttribute('src')).toBe(false);
});
test.each([
['https://tracker.example/pixel.gif'],
['http://tracker.example/pixel.gif'],
// Protocol-relative: starts with a slash, but loads from another host.
['//tracker.example/pixel.gif'],
])('an image from another site (%s) is not loaded, it becomes a link', (src) => {
const div = renderToDOM(`![pixel](${src})`);
expect(div.querySelector('img')).toBeNull();
const a = div.querySelector('a');
expect(a.getAttribute('href')).toBe(src);
expect(a.textContent).toBe('pixel');
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
test('an image from another site with no alt text is labeled by its URL', () => {
const a = renderToDOM('![](https://example.com/a.png)').querySelector('a');
expect(a.textContent).toBe('https://example.com/a.png');
});
test('an image from another site inside a link becomes the link text, not a nested link', () => {
// The badge pattern common in READMEs.
const div = renderToDOM('[![build status](https://ci.example/badge.svg)](https://ci.example/project)');
expect(div.querySelector('img')).toBeNull();
const links = div.querySelectorAll('a');
expect(links).toHaveLength(1);
expect(links[0].getAttribute('href')).toBe('https://ci.example/project');
expect(links[0].textContent).toBe('build status');
});
test('media and embedded data images still load', () => {
const [media, data] = renderToDOM('![a](/media/x/a.png)\n\n![b](data:image/png;base64,AAAA)')
.querySelectorAll('img');
expect(media.getAttribute('src')).toBe('/media/x/a.png');
expect(data.getAttribute('src')).toBe('data:image/png;base64,AAAA');
});
test('external links open in a new tab without an opener', () => {
const a = renderToDOM('<https://example.com/page>').querySelector('a');