Fixes RSS parsing and hardens security against XSS

This commit is contained in:
Alicia Sykes 2026-02-15 17:44:10 +00:00
parent 862dda0084
commit f467b2ea36
4 changed files with 190 additions and 18 deletions

View file

@ -25,6 +25,7 @@
"axios": "^1.12.0",
"connect-history-api-fallback": "^1.6.0",
"crypto-js": "^4.2.0",
"dompurify": "^3.0.8",
"express": "^4.17.2",
"express-basic-auth": "^1.2.1",
"frappe-charts": "^1.6.2",
@ -107,5 +108,6 @@
"browserslist": [
"> 1%",
"last 2 versions"
]
],
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View file

@ -1,17 +1,31 @@
<template>
<div class="rss-wrapper">
<!-- Feed Meta Info -->
<a class="meta-container" v-if="meta" :href="meta.link" :title="meta.description">
<component
:is="meta && meta.link ? 'a' : 'div'"
class="meta-container"
v-if="meta"
:href="meta.link || undefined"
:title="meta.description"
target="_blank"
rel="noopener noreferrer"
>
<img class="feed-icon" :src="meta.image" v-if="meta.image" alt="Feed Image" />
<div class="feed-text">
<p class="feed-title">{{ meta.title }}</p>
<p class="feed-author" v-if="meta.author">By {{ meta.author }}</p>
</div>
</a>
</component>
<!-- Feed Content -->
<div class="post-wrapper" v-if="posts">
<div class="post-row" v-for="(post, indx) in posts" :key="indx">
<a class="post-top" :href="post.link">
<component
:is="post.link ? 'a' : 'div'"
class="post-top"
:href="post.link || undefined"
target="_blank"
rel="noopener noreferrer"
>
<img class="post-img" :src="post.image" v-if="post.image" alt="Post Image">
<div class="post-title-wrap">
<p class="post-title">{{ post.title }}</p>
@ -19,9 +33,15 @@
{{ post.date | formatDate }} {{ post.author | formatAuthor }}
</p>
</div>
</a>
</component>
<div class="post-body" v-html="post.description"></div>
<a class="continue-reading-btn" :href="post.link">
<a
class="continue-reading-btn"
v-if="post.link"
:href="post.link"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('widgets.general.open-link') }}
</a>
</div>
@ -34,6 +54,7 @@
import * as Parser from 'rss-parser';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { sanitizeRssItem, sanitizeRssMeta } from '@/utils/Sanitizer';
export default {
mixins: [WidgetMixin],
@ -68,7 +89,7 @@ export default {
return 'pubDate';
},
orderDirection() {
const usersChoice = this.options.orderBy;
const usersChoice = this.options.orderDirection;
if (usersChoice && (usersChoice === 'desc' || usersChoice === 'asc')) return usersChoice;
return 'desc';
},
@ -87,9 +108,13 @@ export default {
},
filters: {
formatDate(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
// Check if date is valid
if (Number.isNaN(date.getTime())) return '';
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
return date.toLocaleDateString(localFormat, dateFormat);
},
formatAuthor(author) {
return author ? `by ${author}` : '';
@ -107,23 +132,23 @@ export default {
const {
link, title, items, author, description, image,
} = await parser.parseString(data);
this.meta = {
this.meta = sanitizeRssMeta({
title,
link,
author,
description,
image,
};
});
this.processItems(items);
} else {
const { feed, items } = data;
this.meta = {
this.meta = sanitizeRssMeta({
title: feed.title,
link: feed.link,
author: feed.author,
description: feed.description,
image: feed.image,
};
});
this.processItems(items);
}
},
@ -134,13 +159,14 @@ export default {
length = Math.min(length, this.limit);
}
for (let i = 0; length > i; i += 1) {
const sanitized = sanitizeRssItem(items[i]);
posts.push({
title: items[i].title,
description: items[i].description,
image: items[i].thumbnail,
author: items[i].author,
date: items[i].pubDate,
link: items[i].link,
title: sanitized.title,
description: sanitized.description,
image: sanitized.thumbnail,
author: sanitized.author,
date: sanitized.pubDate,
link: sanitized.link,
});
}
this.posts = posts;

132
src/utils/Sanitizer.js Normal file
View file

@ -0,0 +1,132 @@
/**
* Sanitization Utilities
* Used by RSS feed widgets, preventing XSS
*/
import DOMPurify from 'dompurify';
import ErrorHandler from '@/utils/ErrorHandler';
// DOMPurify settings
const HTML_SANITIZE_CONFIG = {
ALLOWED_TAGS: [
'a', 'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's', 'strike',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'img', 'figure', 'figcaption',
'span', 'div', 'hr',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
ALLOWED_ATTR: [
'href', 'src', 'alt', 'title', 'class', 'id',
'width', 'height', 'target', 'rel',
],
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'base', 'form', 'input', 'button'],
KEEP_CONTENT: true,
SAFE_FOR_TEMPLATES: true,
};
// DOMPurify configuration for text-only sanitization
const TEXT_SANITIZE_CONFIG = {
ALLOWED_TAGS: [],
KEEP_CONTENT: true,
};
/**
* Sanitizes HTML content from RSS feeds, to only allow safe tags
* @param {string} html - The HTML content to sanitize
* @returns {string} Sanitized HTML safe for rendering with v-html
*/
export const sanitizeHtml = (html) => {
if (!html || typeof html !== 'string') return '';
try {
return DOMPurify.sanitize(html, HTML_SANITIZE_CONFIG);
} catch (error) {
ErrorHandler('HTML sanitization error', error);
return '';
}
};
/**
* Validates and sanitizes URLs from RSS feeds, only accept http/https
* @param {string} url - The URL to validate
* @returns {string|null} Sanitized URL or null if invalid/malicious
*/
export const sanitizeUrl = (url) => {
if (!url || typeof url !== 'string') return null;
try {
const trimmedUrl = url.trim();
if (!trimmedUrl) return null;
const parsedUrl = new URL(trimmedUrl);
if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
return trimmedUrl;
}
return null;
} catch (error) {
return null;
}
};
/**
* Sanitizes text fields by stripping all HTML tags
* Use for titles, author names, and other text-only fields
* @param {string} text - The text to sanitize
* @returns {string} Plain text with HTML stripped
*/
export const sanitizeText = (text) => {
if (!text || typeof text !== 'string') return '';
try {
return DOMPurify.sanitize(text, TEXT_SANITIZE_CONFIG);
} catch (error) {
ErrorHandler('Text sanitization error', error);
return '';
}
};
/**
* Sanitizes all fields of an RSS item
* @param {object} item - RSS item with title, description, link, etc.
* @returns {object} Sanitized RSS item
*/
export const sanitizeRssItem = (item) => {
if (!item || typeof item !== 'object') return {};
try {
return {
title: sanitizeText(item.title || ''),
description: sanitizeHtml(item.description || item.content || item.contentSnippet || ''),
link: sanitizeUrl(item.link),
author: sanitizeText(item.author || ''),
pubDate: sanitizeText(item.pubDate || item.isoDate || ''),
thumbnail: sanitizeUrl(item.thumbnail || item.enclosure?.url),
};
} catch (error) {
ErrorHandler('RSS item sanitization error', error);
return {};
}
};
/**
* Sanitizes RSS feed metadata
* @param {object} meta - Feed metadata with title, link, description, etc.
* @returns {object} Sanitized feed metadata
*/
export const sanitizeRssMeta = (meta) => {
if (!meta || typeof meta !== 'object') return {};
try {
return {
title: sanitizeText(meta.title || ''),
description: sanitizeText(meta.description || ''),
link: sanitizeUrl(meta.link),
author: sanitizeText(meta.author || ''),
image: sanitizeUrl(meta.image),
};
} catch (error) {
ErrorHandler('RSS metadata sanitization error', error);
return {};
}
};

View file

@ -1519,6 +1519,11 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.12.tgz#bc2cab12e87978eee89fb21576b670350d6d86ab"
integrity sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q==
"@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@types/uglify-js@*":
version "3.17.5"
resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.5.tgz#905ce03a3cbbf2e31cbefcbc68d15497ee2e17df"
@ -4254,6 +4259,13 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
dependencies:
domelementtype "^2.2.0"
dompurify@^3.0.8:
version "3.3.1"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.3.1.tgz#c7e1ddebfe3301eacd6c0c12a4af284936dbbb86"
integrity sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
domutils@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"