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:
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ function asLink(el: HTMLElement, current: boolean){
|
|||||||
}
|
}
|
||||||
const ESCAPED: Record<string,string> ={'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
const ESCAPED: Record<string,string> ={'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user