From 708b69c083003fd14af782f4771822687f64ef8f Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 23 Oct 2026 13:07:47 +0000 Subject: [PATCH] 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 --- tests/page-unit.js | 8 ++++++++ web/CHANGELOG.md | 4 ++++ web/src/dialogs.ts | 2 +- web/src/items.ts | 4 ++-- web/src/player.ts | 2 +- web/src/util.ts | 2 ++ 6 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/page-unit.js b/tests/page-unit.js index 4bdf521..479b7dd 100644 --- a/tests/page-unit.js +++ b/tests/page-unit.js @@ -27,6 +27,14 @@ test('esc makes text safe to put in markup and attributes', () => { 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', () => { assert.equal(run('clock(0)'), '0:00'); assert.equal(run('clock(65)'), '1:05'); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 2c1eb6a..1a5d652 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -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). - 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 ### Added diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 8bebe65..345413d 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -281,7 +281,7 @@ async function fillShow(box: HTMLElement,p: DirFeed){ list.innerHTML=items.length?items.map(e=>{ 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); - return `
${e.link?`${esc(name.text)}`:esc(name.text)}` + return `
${webUrl(e.link)?`${esc(name.text)}`:esc(name.text)}` +(text?`

${esc(text)}

`:'') +(when.length?`${when.map(s=>`${s}`).join('')}`:'')+'
'; }).join(''):'

Nothing read from it yet. The directory checks each feed every few hours.

'; diff --git a/web/src/items.ts b/web/src/items.ts index eefe6cc..c35d69e 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -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} - ${e.link?`${ICON.open} `:''} @@ -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 // 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. - const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held'; + const viewable = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held'; return `
${kindIcon(x)} ${size} diff --git a/web/src/player.ts b/web/src/player.ts index 98b62eb..e4473d0 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -261,7 +261,7 @@ const KEYS: Recordvoid>={ // The toolbar's own buttons, so a key does exactly what the click does, and nothing while // they are disabled. o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#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(), r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }, '[':()=>matchMedia('(max-width:820px)').matches diff --git a/web/src/util.ts b/web/src/util.ts index 882473f..53a7323 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -18,6 +18,8 @@ function asLink(el: HTMLElement, current: boolean){ } const ESCAPED: Record ={'&':'&','<':'<','>':'>','"':'"',"'":'''}; 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 // 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