Feed links are followed only when they are web addresses (#326)

An item's link and an enclosure's URL are the publisher's, stored as
written, and the page put them in href and window.open through esc(),
which escapes characters, not schemes: a javascript: or data: link
survived as Open the original, View in a new tab (for a file not on
disk), the Directory's item links and the v key. Show notes already
lose those schemes to ammonia; these four sinks were the exception.

webUrl() passes http(s) only, and each sink goes through it. A link
that fails it shows no link, as an item with none does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 13:07:47 +00:00
parent 62a2456dde
commit 708b69c083
6 changed files with 18 additions and 4 deletions

View File

@@ -27,6 +27,14 @@ test('esc makes text safe to put in markup and attributes', () => {
assert.equal(run('esc(undefined)'), ''); assert.equal(run('esc(undefined)'), '');
}); });
test('webUrl keeps only web addresses from a feed', () => {
assert.equal(run(`webUrl('https://example.com/a')`), 'https://example.com/a');
assert.equal(run(`webUrl('HTTP://example.com/')`), 'HTTP://example.com/');
for (const u of ['javascript:alert(1)', ' javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '//example.com', ''])
assert.equal(run(`webUrl(${JSON.stringify(u)})`), null, u);
assert.equal(run('webUrl(null)'), null);
});
test('clock reads seconds as a player does', () => { test('clock reads seconds as a player does', () => {
assert.equal(run('clock(0)'), '0:00'); assert.equal(run('clock(0)'), '0:00');
assert.equal(run('clock(65)'), '1:05'); assert.equal(run('clock(65)'), '1:05');

View File

@@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A feed's page drops an error once a later check succeeds, and its item count and when it was last checked stay current, instead of keeping what they were when you opened it (#316). - A feed's page drops an error once a later check succeeds, and its item count and when it was last checked stay current, instead of keeping what they were when you opened it (#316).
- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309). - Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309).
### Security
- Open the original, an episode's View in a new tab, the Directory's item links and the `v` key follow a link only when it is a web address. A podcast's `javascript:` or `data:` link was followed as it was (#326).
## [1.2.0] - 2026-10-19 ## [1.2.0] - 2026-10-19
### Added ### Added

View File

@@ -281,7 +281,7 @@ async function fillShow(box: HTMLElement,p: DirFeed){
list.innerHTML=items.length?items.map(e=>{ list.innerHTML=items.length?items.map(e=>{
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500); const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean); const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean);
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>` return `<article class="lep"><b>${webUrl(e.link)?`<a href="${esc(webUrl(e.link))}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
+(text?`<p>${esc(text)}</p>`:'') +(text?`<p>${esc(text)}</p>`:'')
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>'; +(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
}).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>'; }).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>';

View File

@@ -415,7 +415,7 @@ function detailHtml(e: Entry){
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button> aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}" <button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button> aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer" ${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a> title="Open the original" aria-label="Open the original">${ICON.open}</a>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span> <button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
</div> </div>
@@ -476,7 +476,7 @@ function encBox(x: Enclosure){
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link // Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied // straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay. // through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held'; const viewable = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
return `<div class="encbox"> return `<div class="encbox">
${kindIcon(x)} ${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>

View File

@@ -261,7 +261,7 @@ const KEYS: Record<string,()=>void>={
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while // The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled. // they are disabled.
o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(), o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); }, v:()=>{ const u=webUrl(cur()?.link); if(u) window.open(u,'_blank','noopener'); },
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(), A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }, r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
'[':()=>matchMedia('(max-width:820px)').matches '[':()=>matchMedia('(max-width:820px)').matches

View File

@@ -18,6 +18,8 @@ function asLink(el: HTMLElement, current: boolean){
} }
const ESCAPED: Record<string,string> ={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}; const ESCAPED: Record<string,string> ={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]); const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
// A feed's link, only if it is a web address: esc() keeps a javascript: or data: one working (#326).
const webUrl = (u: string|null|undefined): string|null => /^https?:\/\//i.test(u??'') ? u! : null;
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com // Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the // License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes // ones used, so there is no font to download and nothing is fetched from anyone else. Each takes