Improve handling of URLs on the frontend.

This commit is contained in:
supahgreg 2025-10-21 04:43:21 +00:00
parent 7b6de15e71
commit c8b80c4041
No known key found for this signature in database
6 changed files with 43 additions and 24 deletions

View File

@ -427,6 +427,31 @@ const App = {
return p.replace(/[&<>"'/]/g, m => map[m]);
},
/**
* Sanitize a URL for safe use in href attributes and window.open()
* @param {string} url - URL to sanitize
* @param {string} fallback - Optional fallback value if URL is invalid (default: empty string)
* @return {string} Safe URL or fallback
*/
sanitizeUrl: function(url, fallback = '') {
if (!url || typeof url !== 'string') return fallback;
// Remove NULL bytes and other control characters
// eslint-disable-next-line no-control-regex
const cleaned = url.replace(/[\x00-\x1F\x7F]/g, '');
const trimmed = cleaned.trim();
if (!trimmed) return fallback;
return /^https?:\/\/.+/i.test(trimmed) ? trimmed : fallback;
},
openUrl: function(url) {
const sanitized = this.sanitizeUrl(url);
if (sanitized) {
const w = window.open(sanitized);
w.opener = null;
}
},
unescapeHtml: function(p) {
if (typeof p !== 'string' || p.indexOf('&') === -1)
return p;

View File

@ -76,12 +76,6 @@ const Article = {
}
}
},
popupOpenUrl: function(url) {
const w = window.open("");
w.opener = null;
w.location = url;
},
cdmToggleGridSpan: function(id) {
const row = document.getElementById(`RROW-${id}`);
@ -160,26 +154,26 @@ const Article = {
<img loading="lazy"
width="${enc.width ? enc.width : ''}"
height="${enc.height ? enc.height : ''}"
src="${App.escapeHtml(enc.content_url)}"
src="${App.escapeHtml(App.sanitizeUrl(enc.content_url))}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"/>
</p>`
} else if (enc.content_type && enc.content_type.indexOf("audio/") !== -1 && App.audioCanPlay(enc.content_type)) {
return `<p class='inline-player' title="${App.escapeHtml(enc.content_url)}">
<audio preload="none" controls="controls">
<source type="${App.escapeHtml(enc.content_type)}" src="${App.escapeHtml(enc.content_url)}"/>
<source type="${App.escapeHtml(enc.content_type)}" src="${App.escapeHtml(App.sanitizeUrl(enc.content_url))}"/>
</audio>
</p>
`;
} else {
return `<p>
<a target="_blank" href="${App.escapeHtml(enc.content_url)}"
<a target="_blank" href="${App.escapeHtml(App.sanitizeUrl(enc.content_url))}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"
rel="noopener noreferrer">${App.escapeHtml(enc.content_url)}</a>
</p>`
}
} else {
return `<p>
<a target="_blank" href="${App.escapeHtml(enc.content_url)}"
<a target="_blank" href="${App.escapeHtml(App.sanitizeUrl(enc.content_url))}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"
rel="noopener noreferrer">${App.escapeHtml(enc.content_url)}</a>
</p>`
@ -191,7 +185,7 @@ const Article = {
<span>${__('Attachments')}</span>
<div dojoType="dijit.Menu" style="display: none">
${enclosures.entries.map((enc) => `
<div onclick='Article.popupOpenUrl("${App.escapeHtml(enc.content_url)}")'
<div onclick="App.openUrl('${enc.content_url}')"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}" dojoType="dijit.MenuItem">
${enc.title ? enc.title : enc.filename}
</div>
@ -233,7 +227,7 @@ const Article = {
comments_msg = hl.num_comments + " " + ngettext("comment", "comments", hl.num_comments)
}
comments = `<a target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.comments ? hl.comments : hl.link)}">(${comments_msg})</a>`;
comments = `<a target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(App.sanitizeUrl(hl.comments ? hl.comments : hl.link))}">(${comments_msg})</a>`;
}
return comments;
@ -288,7 +282,7 @@ const Article = {
<div class="row">
<div class="title"><a target="_blank" rel="noopener noreferrer"
title="${App.escapeHtml(hl.title)}"
href="${App.escapeHtml(hl.link)}">${hl.title}</a></div>
href="${App.escapeHtml(App.sanitizeUrl(hl.link))}">${hl.title}</a></div>
<div class="date">${hl.updated_long}</div>
</div>
<div class="row">

View File

@ -609,7 +609,7 @@ const CommonDialogs = {
</section>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Icon')}">
<div><img class='feedIcon' style="${feed.icon ? "" : "display : none"}" src="${feed.icon ? App.escapeHtml(feed.icon) : ""}"></div>
<div><img class='feedIcon' style="${feed.icon ? "" : "display : none"}" src="${feed.icon ? App.escapeHtml(App.sanitizeUrl(feed.icon)) : ""}"></div>
<label class="dijitButton">
${App.FormFields.icon("file_upload")}
@ -674,7 +674,7 @@ const CommonDialogs = {
<header>${__("%s can be accessed via the following secret URL:").replace("%s", App.escapeHtml(reply.title))}</header>
<section>
<div class='panel text-center'>
<a class='generated_url' href="${App.escapeHtml(reply.link)}" target='_blank'>${App.escapeHtml(reply.link)}</a>
<a class='generated_url' href="${App.escapeHtml(App.sanitizeUrl(reply.link))}" target='_blank'>${App.escapeHtml(reply.link)}</a>
</div>
</section>
<footer>

View File

@ -741,7 +741,7 @@ const Feeds = {
const icon_url = App.getInitParam('icons_url') + '?' + dojo.objectToQuery({op: 'feed_icon', id: feed_id});
return feed_id && exists ?
`<img class='icon' src='${App.escapeHtml(icon_url)}' width='16' height='16' alt='feed icon' onerror='Feeds._handleIconError(this)'>` :
`<img class='icon' src='${App.escapeHtml(App.sanitizeUrl(icon_url))}' width='16' height='16' alt='feed icon' onerror='Feeds._handleIconError(this)'>` :
`<i class='icon-no-feed material-icons'>rss_feed</i>`;
},
_handleIconError: (img) => {

View File

@ -463,7 +463,7 @@ const Headlines = {
const vgrhdr = `<div data-feed-id='${hl.feed_id}' class='feed-title'>
<div class="pull-right icon-feed" title="${App.escapeHtml(hl.feed_title)}"
onclick="Feeds.open({feed:${hl.feed_id}})">${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</div>
<a class="title" title="${__('Open site')}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.site_url)}">${hl.feed_title}</a>
<a class="title" title="${__('Open site')}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(App.sanitizeUrl(hl.site_url))}">${hl.feed_title}</a>
<a class="catchup" title="${__('mark feed as read')}" onclick="Feeds.catchupFeedInGroup(${hl.feed_id})" href="#">
<i class="icon-done material-icons">done_all</i>
</a>
@ -504,7 +504,7 @@ const Headlines = {
<span onclick="return Headlines.click(event, ${hl.id});" data-article-id="${hl.id}" class="titleWrap hlMenuAttach">
${App.getInitParam("debug_headline_ids") ? `<span class="text-muted small">A: ${hl.id} F: ${hl.feed_id}</span>` : ""}
<a class="title" title="${App.escapeHtml(hl.title)}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.link)}">
<a class="title" title="${App.escapeHtml(hl.title)}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(App.sanitizeUrl(hl.link))}">
${hl.title}</a>
<span class="author">${hl.author}</span>
${Article.renderLabels(hl.id, hl.labels)}
@ -512,7 +512,7 @@ const Headlines = {
</span>
<a class="feed vfeedMenuAttach" style="background-color: ${hl.feed_bg_color}" data-feed-id="${hl.feed_id}"
title="${__('Open site')}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.site_url)}">${hl.feed_title}</a>
title="${__('Open site')}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(App.sanitizeUrl(hl.site_url))}">${hl.feed_title}</a>
<span class="updated" title="${hl.imported}">${hl.updated}</span>
@ -575,13 +575,13 @@ const Headlines = {
<div onclick="return Headlines.click(event, ${hl.id})" class="title">
${App.getInitParam("debug_headline_ids") ? `<span class="text-muted small">A: ${hl.id} F: ${hl.feed_id}</span>` : ""}
<span data-article-id="${hl.id}" class="hl-content hlMenuAttach">
<a class="title" href="${App.escapeHtml(hl.link)}">${hl.title} <span class="preview">${hl.content_preview}</span></a>
<a class="title" href="${App.escapeHtml(App.sanitizeUrl(hl.link))}">${hl.title} <span class="preview">${hl.content_preview}</span></a>
<span class="author">${hl.author}</span>
${Article.renderLabels(hl.id, hl.labels)}
</span>
</div>
<span class="feed vfeedMenuAttach" data-feed-id="${hl.feed_id}">
<a title="${__('Open site')}" style="background : ${hl.feed_bg_color}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.site_url)}">${hl.feed_title}</a>
<a title="${__('Open site')}" style="background : ${hl.feed_bg_color}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(App.sanitizeUrl(hl.site_url))}">${hl.feed_title}</a>
</span>
<div title="${hl.imported}">
<span class="updated">${hl.updated}</span>
@ -637,7 +637,7 @@ const Headlines = {
<i class='icon-syndicate material-icons'>rss_feed</i>
</a>
${tb.site_url ?
`<a class="feed_title" target="_blank" href="${App.escapeHtml(tb.site_url)}" title="${tb.last_updated}">${tb.title}</a>` :
`<a class="feed_title" target="_blank" href="${App.escapeHtml(App.sanitizeUrl(tb.site_url))}" title="${tb.last_updated}">${tb.title}</a>` :
`${search_query ? `<a href="#" onclick="Feeds.search(); return false" class="feed_title" title="${App.escapeHtml(search_query)}">${tb.title}</a>
<span class="cancel_search">(<a href="#" onclick="Feeds.cancelSearch(); return false">${__("Cancel search")}</a>)</span>` :
`<span class="feed_title">${tb.title}</span>`}`}

View File

@ -411,7 +411,7 @@ const Helpers = {
{disabled: true}) : ''}
${plugin.more_info ?
App.FormFields.button_tag(App.FormFields.icon("help"), "",
{class: 'alt-info', onclick: `window.open("${App.escapeHtml(plugin.more_info)}")`}) : ''}
{class: 'alt-info', onclick: `App.openUrl('${plugin.more_info}')`}) : ''}
${is_admin && plugin.is_local ?
App.FormFields.button_tag(App.FormFields.icon("update"), "",
{title: __("Update"), class: 'alt-warning', "data-update-btn-for-plugin": plugin.name, style: 'display : none',
@ -578,7 +578,7 @@ const Helpers = {
onclick: `App.dialogOf(this).performInstall("${App.escapeHtml(plugin.name)}")`})}
<h3>${plugin.name}
<a target="_blank" href="${App.escapeHtml(plugin.html_url)}">
<a target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(App.sanitizeUrl(plugin.html_url))}">
${App.FormFields.icon("open_in_new_window")}
</a>
</h3>