mirror of
https://github.com/Lissy93/dashy.git
synced 2026-03-07 05:12:48 +01:00
Fixes RSS parsing and hardens security against XSS
This commit is contained in:
parent
862dda0084
commit
f467b2ea36
4 changed files with 190 additions and 18 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
132
src/utils/Sanitizer.js
Normal 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 {};
|
||||
}
|
||||
};
|
||||
12
yarn.lock
12
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue