diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 035f9152b8..e476efd513 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -6,6 +6,7 @@ const fs = require('../libs/fsExtra') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils') +const { validateUrl } = require('../utils/index') const Scanner = require('../scanner/Scanner') const CoverManager = require('../managers/CoverManager') @@ -102,15 +103,24 @@ class PodcastController { } } + /** + * POST: /api/podcasts/feed + * + * @typedef getPodcastFeedReqBody + * @property {string} rssFeed + * + * @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req + * @param {import('express').Response} res + */ async getPodcastFeed(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get podcast feed`) return res.sendStatus(403) } - var url = req.body.rssFeed + const url = validateUrl(req.body.rssFeed) if (!url) { - return res.status(400).send('Bad request') + return res.status(400).send('Invalid request body. "rssFeed" must be a valid URL') } const podcast = await getPodcastFeed(url) diff --git a/server/utils/index.js b/server/utils/index.js index 0377b1731d..29a65885dc 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -11,24 +11,24 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => { str2 = str2.toLowerCase() } const track = Array(str2.length + 1).fill(null).map(() => - Array(str1.length + 1).fill(null)); + Array(str1.length + 1).fill(null)) for (let i = 0; i <= str1.length; i += 1) { - track[0][i] = i; + track[0][i] = i } for (let j = 0; j <= str2.length; j += 1) { - track[j][0] = j; + track[j][0] = j } for (let j = 1; j <= str2.length; j += 1) { for (let i = 1; i <= str1.length; i += 1) { - const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; + const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1 track[j][i] = Math.min( track[j][i - 1] + 1, // deletion track[j - 1][i] + 1, // insertion track[j - 1][i - 1] + indicator, // substitution - ); + ) } } - return track[str2.length][str1.length]; + return track[str2.length][str1.length] } module.exports.levenshteinDistance = levenshteinDistance @@ -204,4 +204,20 @@ module.exports.asciiOnlyToLowerCase = (str) => { module.exports.escapeRegExp = (str) => { if (typeof str !== 'string') return '' return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Validate url string with URL class + * + * @param {string} rawUrl + * @returns {string} null if invalid + */ +module.exports.validateUrl = (rawUrl) => { + if (!rawUrl || typeof rawUrl !== 'string') return null + try { + return new URL(rawUrl).toString() + } catch (error) { + Logger.error(`Invalid URL "${rawUrl}"`, error) + return null + } } \ No newline at end of file diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 87b080d750..819ec91411 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -1,5 +1,6 @@ -const Logger = require('../Logger') const axios = require('axios') +const ssrfFilter = require('ssrf-req-filter') +const Logger = require('../Logger') const { xmlToJSON, levenshteinDistance } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') @@ -216,9 +217,26 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal } } +/** + * Get podcast RSS feed as JSON + * Uses SSRF filter to prevent internal URLs + * + * @param {string} feedUrl + * @param {boolean} [excludeEpisodeMetadata=false] + * @returns {Promise} + */ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) - return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml' } }).then(async (data) => { + + return axios({ + url: feedUrl, + method: 'GET', + timeout: 12000, + responseType: 'arraybuffer', + headers: { Accept: 'application/rss+xml' }, + httpAgent: ssrfFilter(feedUrl), + httpsAgent: ssrfFilter(feedUrl) + }).then(async (data) => { // Adding support for ios-8859-1 encoded RSS feeds. // See: https://github.com/advplyr/audiobookshelf/issues/1489 @@ -231,12 +249,12 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { if (!data?.data) { Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`) - return false + return null } Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`) const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) if (!payload) { - return false + return null } // RSS feed may be a private RSS feed @@ -245,7 +263,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { return payload.podcast }).catch((error) => { Logger.error('[podcastUtils] getPodcastFeed Error', error) - return false + return null }) }