VYPR
High severityNVD Advisory· Published Jan 13, 2023· Updated Mar 10, 2025

RSSHub is vulnerable to SSRF (Server-Side Request Forgery)

CVE-2023-22493

Description

RSSHub contains an SSRF vulnerability allowing arbitrary HTTP requests from the server, risking access to internal or external sensitive resources.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

RSSHub contains an SSRF vulnerability allowing arbitrary HTTP requests from the server, risking access to internal or external sensitive resources.

Vulnerability

Overview RSSHub, an open-source RSS feed generator, is vulnerable to a Server-Side Request Forgery (SSRF) attack. The root cause lies in insufficient validation of user-supplied input used as a hostname in URL construction. Attackers can supply a malicious URL containing URL-encoded characters (e.g., %2F for / and %23 for #) to manipulate the resulting request target [2]. This flaw allows the server to be tricked into sending requests to arbitrary attacker-controlled domains or internal network resources [1].

Exploitation

Method To exploit this vulnerability, an attacker sends a request to an affected RSSHub route with a crafted malicious URL parameter. For example, providing a value like ATTACKER.HOST%2F%23 causes the server to form a URL such as https://ATTACKER.HOST/#.defined.host, thereby redirecting the request to the attacker's domain [2]. The attack does not require authentication and can be executed remotely over the network. The attacker can direct requests to any accessible host, including internal services that are normally isolated [1].

Impact

Successful exploitation enables the attacker to perform arbitrary HTTP requests from the RSSHub server. This can lead to information disclosure by accessing sensitive data on internal servers, port scanning internal networks, or leveraging the server as a proxy for further attacks. Because the server may have trust relationships with internal systems, the impact is amplified compared to a similar attack from an external client [1][2].

Mitigation

The official patch is contained in commit a66cbcf [1], which introduces a configuration toggle ALLOW_USER_SUPPLY_UNSAFE_DOMAIN [4]. By default, this feature is disabled, preventing the vulnerable behavior. Instances are advised to update to the patched version and, for public instances, leave this option turned off. Additional mitigation includes validating user-supplied hostnames using the approach described in the advisory: splitting by . and verifying each segment against RFC 1034 subdomain rules [2].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
rsshubnpm
< 1.0.0-master.a66cbcf1.0.0-master.a66cbcf

Affected products

2

Patches

1
a66cbcf6eebc

feat(config)!: unsafe domain toggle (#11588)

https://github.com/diygod/rsshubTonyJan 10, 2023via ghsa
66 files changed · +339 16
  • docs/en/install/README.md+3 1 modified
    @@ -564,14 +564,16 @@ It is also valid to contain route parameters, e.g. `/weibo/user/2612249974`.
     
     ::: tip Experimental features
     
    -Configs in this sections are in beta stage, and are turn off by default. Please read corresponded description and turn on if necessary.
    +Configs in this sections are in beta stage, and **are turn off by default**. Please read corresponded description and turn on if necessary.
     
     :::
     
     `ALLOW_USER_HOTLINK_TEMPLATE`: [Parameters->Multimedia processing](/en/parameter.html#multimedia-processing)
     
     `FILTER_REGEX_ENGINE`: Define Regex engine used in [Parameters->filtering](/en/parameter.html#filtering). Valid value are `[re2, regexp]`. Default value is `re2`. We suggest public instance should leave this value to default, and this option right now is mainly for backward compatibility.
     
    +`ALLOW_USER_SUPPLY_UNSAFE_DOMAIN`: allow users to provide a domain as a parameter to routes that are not in their allow list, respectively. Public instances are suggested to leave this value default, as it may lead to [Server-Side Request Forgery (SSRF)](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery)
    +
     ### Other Application Configurations
     
     `DISALLOW_ROBOT`: prevent indexing by search engine, default to enable, set false or 0 to disable
    
  • docs/install/README.md+3 1 modified
    @@ -571,14 +571,16 @@ RSSHub 支持使用访问密钥 / 码,白名单和黑名单三种方式进行
     
     ::: tip 测试特性
     
    -这个板块控制的是一些新特性的选项,默认他们都是关闭的。如果有需要请阅读对应说明后按需开启
    +这个板块控制的是一些新特性的选项,他们都是**默认关闭**的。如果有需要请阅读对应说明后按需开启
     
     :::
     
     `ALLOW_USER_HOTLINK_TEMPLATE`: [通用参数 -> 多媒体处理](/parameter.html#duo-mei-ti-chu-li)特性控制
     
     `FILTER_REGEX_ENGINE`: 控制 [通用参数 -> 内容过滤](/parameter.html#nei-rong-guo-lu) 使用的正则引擎。可选`[re2, regexp]`,默认`re2`。我们推荐公开实例不要调整这个选项,这个选项目前主要用于向后兼容。
     
    +`ALLOW_USER_SUPPLY_UNSAFE_DOMAIN`: 允许用户为路由提供域名作为参数。建议公共实例不要调整此选项,开启后可能会导致 [服务端请求伪造(SSRF)](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery)
    +
     ### 其他应用配置
     
     `DISALLOW_ROBOT`: 阻止搜索引擎收录,默认开启,设置 false 或 0 关闭
    
  • lib/config.js+1 0 modified
    @@ -97,6 +97,7 @@ const calculateValue = () => {
             feature: {
                 allow_user_hotlink_template: envs.ALLOW_USER_HOTLINK_TEMPLATE === 'true',
                 filter_regex_engine: envs.FILTER_REGEX_ENGINE || 're2',
    +            allow_user_supply_unsafe_domain: envs.ALLOW_USER_SUPPLY_UNSAFE_DOMAIN === 'true',
             },
             suffix: envs.SUFFIX,
             titleLengthLimit: parseInt(envs.TITLE_LENGTH_LIMIT) || 150,
    
  • lib/routes/bandisoft/index.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const lang = ctx.params.lang || 'en';
         const id = ctx.params.id || 'bandizip';
    +    if (!isValidHost(lang)) {
    +        throw Error('Invalid language code');
    +    }
     
         const rootUrl = `https://${lang}.bandisoft.com`;
         const currentUrl = `${rootUrl}/${id}/history/`;
    
  • lib/routes/biobio/others.js+5 0 modified
    @@ -1,7 +1,12 @@
     const cheerio = require('cheerio');
     const got = require('@/utils/got');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
    +    if (!isValidHost(ctx.params.column)) {
    +        throw Error('Invalid column');
    +    }
    +
         const url = `http://${ctx.params.column}.bio1000.com/${ctx.params.id}`;
         const res = await got.get(url);
         const $ = cheerio.load(res.data);
    
  • lib/routes/blogs/hedwig.js+4 0 modified
    @@ -4,9 +4,13 @@ const md = require('markdown-it')({
         html: true,
     });
     const dayjs = require('dayjs');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const type = ctx.params.type;
    +    if (!isValidHost(type)) {
    +        throw Error('Invalid type');
    +    }
     
         const url = `https://${type}.hedwig.pub`;
         const res = await got({
    
  • lib/routes/blogs/wordpress.js+5 0 modified
    @@ -1,7 +1,12 @@
     const parser = require('@/utils/rss-parser');
     const config = require('@/config').value;
    +const allowDomain = ['lawrence.code.blog'];
     
     module.exports = async (ctx) => {
    +    if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.includes(ctx.params.domain)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
    +
         const scheme = ctx.params.https || 'https';
         const cdn = config.wordpress.cdnUrl;
     
    
  • lib/routes/booth-pm/shop.js+4 1 modified
    @@ -1,10 +1,13 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    -
    +const { isValidHost } = require('@/utils/valid-host');
     const maxPages = 5;
     
     module.exports = async (ctx) => {
         const { subdomain } = ctx.params;
    +    if (!isValidHost(subdomain)) {
    +        throw Error('Invalid subdomain');
    +    }
         const shopUrl = `https://${subdomain}.booth.pm`;
     
         let shopName;
    
  • lib/routes/caixin/blog.js+4 0 modified
    @@ -1,6 +1,7 @@
     const got = require('@/utils/got');
     const parser = require('@/utils/rss-parser');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     async function load(link, need_feed_description) {
         const response = await got.get(link);
    @@ -29,6 +30,9 @@ async function load(link, need_feed_description) {
     
     module.exports = async (ctx) => {
         const { column } = ctx.params;
    +    if (!isValidHost(column)) {
    +        throw Error('Invalid column');
    +    }
         const link = `http://${column}.blog.caixin.com`;
         const feed_url = `${link}/feed`;
         const feed = await parser.parseURL(feed_url);
    
  • lib/routes/caixin/category.js+4 0 modified
    @@ -1,10 +1,14 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const category = ctx.params.category;
         const column = ctx.params.column;
         const url = `http://${column}.caixin.com/${category}`;
    +    if (!isValidHost(column)) {
    +        throw Error('Invalid column');
    +    }
     
         const response = await got({
             method: 'get',
    
  • lib/routes/craigslist/search.js+5 0 modified
    @@ -1,7 +1,12 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
    +    if (!isValidHost(ctx.params.location)) {
    +        throw Error('Invalid location');
    +    }
    +
         const queryParams = ctx.request.querystring;
         const queryUrl = `https://${ctx.params.location}.craigslist.org/search/${ctx.params.type}?${queryParams}`;
         const { data } = await got.get(queryUrl);
    
  • lib/routes/engadget/home.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const parser = require('@/utils/rss-parser');
    +const allowLang = ['chinese', 'cn', 'us', 'japanese', 'www'];
     
     module.exports = async (ctx) => {
         const lang = ctx.params.lang === 'us' ? 'www' : ctx.params.lang || 'cn';
    +    if (!allowLang.includes(lang)) {
    +        throw Error('Invalid lang');
    +    }
         const rssUrl = `https://${lang}.engadget.com/rss.xml`;
         const feed = await parser.parseURL(rssUrl);
     
    
  • lib/routes/fanbox/main.js+4 1 modified
    @@ -4,12 +4,15 @@
     // user?: fanbox domain name
     
     const got = require('@/utils/got');
    -
    +const { isValidHost } = require('@/utils/valid-host');
     const conv_item = require('./conv');
     const get_header = require('./header');
     
     module.exports = async (ctx) => {
         const user = ctx.params.user || 'official'; // if no user specified, just go to official page
    +    if (!isValidHost(user)) {
    +        throw Error('Invalid user');
    +    }
         const box_url = `https://${user}.fanbox.cc`;
     
         // get user info
    
  • lib/routes/fashionnetwork/headline.js+4 0 modified
    @@ -1,8 +1,12 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const country = ctx.params.country || 'ww';
    +    if (!isValidHost(country)) {
    +        throw Error('Invalid country');
    +    }
     
         const rootUrl = `https://${country}.fashionnetwork.com`;
         const response = await got({
    
  • lib/routes/fashionnetwork/news.js+5 0 modified
    @@ -1,5 +1,6 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const country = ctx.params.country || 'ww';
    @@ -12,6 +13,10 @@ module.exports = async (ctx) => {
         const sectorsUrl = sectors ? 'sectors%5B%5D=' + sectors.split(',').join('&sectors%5B%5D=') : '';
         const categoriesUrl = categories ? 'categs%5B%5D=' + categories.split(',').join('&categs%5B%5D=') : '';
     
    +    if (!isValidHost(country)) {
    +        throw Error('Invalid country');
    +    }
    +
         const rootUrl = `https://${country}.fashionnetwork.com`;
         const currentUrl = `${rootUrl}/news/s.jsonp?${sectorsUrl}&${categoriesUrl}`;
         const response = await got({
    
  • lib/routes/gitlab/common.js+5 0 added
    @@ -0,0 +1,5 @@
    +const allowHost = ['gitlab.com'];
    +
    +module.exports = {
    +    allowHost,
    +};
    
  • lib/routes/gitlab/explore.js+5 0 modified
    @@ -1,5 +1,7 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const config = require('@/config').value;
    +const { allowHost } = require('./common');
     
     module.exports = async (ctx) => {
         let { type, host } = ctx.params;
    @@ -10,6 +12,9 @@ module.exports = async (ctx) => {
             starred: 'Most stars',
             all: 'All',
         };
    +    if (!config.feature.allow_user_supply_unsafe_domain && !allowHost.includes(new URL(host).hostname)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         const res = await got({
             method: 'get',
    
  • lib/routes/gitlab/release.js+5 0 modified
    @@ -1,8 +1,13 @@
     const got = require('@/utils/got');
     const { parseDate } = require('@/utils/parse-date');
    +const config = require('@/config').value;
    +const { allowHost } = require('./common');
     
     module.exports = async (ctx) => {
         const { namespace, project, host } = ctx.params;
    +    if (!config.feature.allow_user_supply_unsafe_domain && !allowHost.includes(host)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         const host_ = host ? host : 'gitlab.com';
         const namespace_ = encodeURIComponent(namespace);
    
  • lib/routes/gitlab/tag.js+5 0 modified
    @@ -1,8 +1,13 @@
     const got = require('@/utils/got');
     const { parseDate } = require('@/utils/parse-date');
    +const config = require('@/config').value;
    +const { allowHost } = require('./common');
     
     module.exports = async (ctx) => {
         const { namespace, project, host } = ctx.params;
    +    if (!config.feature.allow_user_supply_unsafe_domain && !allowHost.includes(host)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         const host_ = host ? host : 'gitlab.com';
         const namespace_ = encodeURIComponent(namespace);
    
  • lib/routes/hexo/fluid.js+4 0 modified
    @@ -1,7 +1,11 @@
     const cheerio = require('cheerio');
     const got = require('@/utils/got');
    +const config = require('@/config').value;
     
     module.exports = async (ctx) => {
    +    if (!config.feature.allow_user_supply_unsafe_domain) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
         const url = `http://${ctx.params.url}`;
         const res = await got.get(`${url}/archives/`);
         const $ = cheerio.load(res.data);
    
  • lib/routes/hexo/next.js+4 0 modified
    @@ -1,7 +1,11 @@
     const cheerio = require('cheerio');
     const got = require('@/utils/got');
    +const config = require('@/config').value;
     
     module.exports = async (ctx) => {
    +    if (!config.feature.allow_user_supply_unsafe_domain) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
         const url = `http://${ctx.params.url}`;
         const res = await got.get(`${url}/archives/`);
         const $ = cheerio.load(res.data);
    
  • lib/routes/hexo/yilia.js+4 0 modified
    @@ -1,7 +1,11 @@
     const cheerio = require('cheerio');
     const got = require('@/utils/got');
    +const config = require('@/config').value;
     
     module.exports = async (ctx) => {
    +    if (!config.feature.allow_user_supply_unsafe_domain) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
         const url = `http://${ctx.params.url}`;
         const res = await got.get(url);
         const $ = cheerio.load(res.data);
    
  • lib/routes/mastodon/account_id.js+4 0 modified
    @@ -1,9 +1,13 @@
     const utils = require('./utils');
    +const config = require('@/config').value;
     
     module.exports = async (ctx) => {
         const site = ctx.params.site;
         const account_id = ctx.params.account_id;
         const only_media = ctx.params.only_media ? 'true' : 'false';
    +    if (!config.feature.allow_user_supply_unsafe_domain && !utils.allowSiteList.includes(site)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         const { account_data, data } = await utils.getAccountStatuses(site, account_id, only_media);
     
    
  • lib/routes/mastodon/timeline_local.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const utils = require('./utils');
    +const config = require('@/config').value;
     
     module.exports = async (ctx) => {
         const site = ctx.params.site;
         const only_media = ctx.params.only_media ? 'true' : 'false';
    +    if (!config.feature.allow_user_supply_unsafe_domain && !utils.allowSiteList.includes(site)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         const url = `http://${site}/api/v1/timelines/public?local=true&only_media=${only_media}`;
     
    
  • lib/routes/mastodon/timeline_remote.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const utils = require('./utils');
    +const config = require('@/config').value;
     
     module.exports = async (ctx) => {
         const site = ctx.params.site;
         const only_media = ctx.params.only_media ? 'true' : 'false';
    +    if (!config.feature.allow_user_supply_unsafe_domain && !utils.allowSiteList.includes(site)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         const url = `http://${site}/api/v1/timelines/public?remote=true&only_media=${only_media}`;
     
    
  • lib/routes/mastodon/utils.js+3 0 modified
    @@ -1,6 +1,8 @@
     const got = require('@/utils/got');
     const { parseDate } = require('@/utils/parse-date');
     
    +const allowSiteList = ['mastodon.social', 'pawoo.net'];
    +
     const parseStatuses = (data) =>
         data.map((item) => {
             // docs on: https://docs.joinmastodon.org/entities/status/
    @@ -125,4 +127,5 @@ module.exports = {
         parseStatuses,
         getAccountStatuses,
         getAccountIdByAcct,
    +    allowSiteList,
     };
    
  • lib/routes/pornhub/category_url.js+4 0 modified
    @@ -1,10 +1,14 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const language = ctx.params.language || 'www';
         const url = ctx.params.url || 'video';
         const link = `https://${language}.pornhub.com/${url}`;
    +    if (!isValidHost(language)) {
    +        throw Error('Invalid language');
    +    }
     
         const response = await got.get(link);
         const $ = cheerio.load(response.data);
    
  • lib/routes/pornhub/model.js+4 0 modified
    @@ -1,11 +1,15 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const language = ctx.params.language || 'www';
         const username = ctx.params.username;
         const sort = ctx.params.sort || 'mr';
         const link = `https://${language}.pornhub.com/model/${username}/videos?o=${sort}`;
    +    if (!isValidHost(language)) {
    +        throw Error('Invalid language');
    +    }
     
         const response = await got.get(link);
         const $ = cheerio.load(response.data);
    
  • lib/routes/pornhub/pornstar.js+4 0 modified
    @@ -1,11 +1,15 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const language = ctx.params.language || 'www';
         const username = ctx.params.username;
         const sort = ctx.params.sort || 'mr';
         const link = `https://${language}.pornhub.com/pornstar/${username}/videos?o=${sort}`;
    +    if (!isValidHost(language)) {
    +        throw Error('Invalid language');
    +    }
     
         const response = await got.get(link);
         const $ = cheerio.load(response.data);
    
  • lib/routes/pornhub/users.js+4 0 modified
    @@ -1,10 +1,14 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const language = ctx.params.language || 'www';
         const username = ctx.params.username;
         const link = `https://${language}.pornhub.com/users/${username}/videos`;
    +    if (!isValidHost(language)) {
    +        throw Error('Invalid language');
    +    }
     
         const response = await got.get(link);
         const $ = cheerio.load(response.data);
    
  • lib/routes/touhougarakuta/index.js+3 0 modified
    @@ -11,6 +11,9 @@ const getBaseUrl = (language) => (language === 'ja' ? 'https://touhougarakuta.co
     
     module.exports = async (ctx) => {
         const { language, type } = ctx.params;
    +    if (!Object.keys(languageCodes).includes(language)) {
    +        throw Error('Invalid language');
    +    }
     
         const baseUrl = getBaseUrl(language);
     
    
  • lib/routes/weforum/report.js+10 6 modified
    @@ -1,13 +1,17 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
    -    ctx.params.lang = ctx.params.lang || 'www';
    -    ctx.params.platform = ctx.params.platform || '';
    -    ctx.params.year = ctx.params.year || '';
    -
    -    const rootUrl = `https://${ctx.params.lang === 'en' ? 'www' : ctx.params.lang}.weforum.org`;
    -    const currentUrl = `${rootUrl}/reports?platform=${ctx.params.platform}&year=${ctx.params.year}`;
    +    const lang = ctx.params.lang || 'www';
    +    const platform = ctx.params.platform || '';
    +    const year = ctx.params.year || '';
    +    if (!isValidHost(lang)) {
    +        throw Error('Invalid lang');
    +    }
    +
    +    const rootUrl = `https://${lang === 'en' ? 'www' : lang}.weforum.org`;
    +    const currentUrl = `${rootUrl}/reports?platform=${platform}&year=${year}`;
         const response = await got({
             method: 'get',
             url: currentUrl,
    
  • lib/routes/yahoo-news/index.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const parser = require('@/utils/rss-parser');
     const cheerio = require('cheerio');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const region = ctx.params.region === 'en' ? '' : ctx.params.region.toLowerCase() + '.';
    +    if (!isValidHost(region)) {
    +        throw Error('Invalid region');
    +    }
         const category = ctx.params.category ? ctx.params.category.toLowerCase() : '';
         const rssUrl = `https://${region}news.yahoo.com/rss/${category}`;
         const feed = await parser.parseURL(rssUrl);
    
  • lib/routes/ziroom/room.js+5 0 modified
    @@ -1,4 +1,5 @@
     const got = require('@/utils/got');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const city = ctx.params.city || 'sh';
    @@ -7,6 +8,10 @@ module.exports = async (ctx) => {
         const room = ctx.params.room || '1';
         const domain = `${city === 'bj' ? '' : city + '.'}m.ziroom.com`;
     
    +    if (!isValidHost(city)) {
    +        throw Error('Invalid city');
    +    }
    +
         const response = await got({
             method: 'post',
             url: `http://${domain}/list/ajax-get-data`,
    
  • lib/utils/valid-host.js+16 0 added
    @@ -0,0 +1,16 @@
    +/**
    + * Check if a sub-domain is valid
    + * @param {String} hostname sub-domain
    + * @returns {Boolean} true if valid
    + */
    +const isValidHost = (hostname) => {
    +    if (typeof hostname !== 'string') {
    +        return false;
    +    }
    +    const regex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
    +    return regex.test(hostname);
    +};
    +
    +module.exports = {
    +    isValidHost,
    +};
    
  • lib/v2/19lou/index.js+4 0 modified
    @@ -3,6 +3,7 @@ const cheerio = require('cheerio');
     const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
     const iconv = require('iconv-lite');
    +const { isValidHost } = require('@/utils/valid-host');
     
     const setCookie = function (cookieName, cookieValue, seconds, path, domain, secure) {
         let expires = null;
    @@ -15,6 +16,9 @@ const setCookie = function (cookieName, cookieValue, seconds, path, domain, secu
     
     module.exports = async (ctx) => {
         const city = ctx.params.city ?? 'www';
    +    if (!isValidHost(city)) {
    +        throw Error('Invalid city');
    +    }
     
         const rootUrl = `https://${city}.19lou.com`;
     
    
  • lib/v2/91porn/author.js+2 0 modified
    @@ -3,11 +3,13 @@ const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const { domainValidation } = require('./utils');
     
     module.exports = async (ctx) => {
         const { domain = '91porn.com' } = ctx.query;
         const { uid, lang = 'en_US' } = ctx.params;
         const siteUrl = `https://${domain}/uvideos.php?UID=${uid}&type=public`;
    +    domainValidation(domain, ctx);
     
         const response = await got.post(siteUrl, {
             form: {
    
  • lib/v2/91porn/index.js+2 0 modified
    @@ -3,11 +3,13 @@ const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const { domainValidation } = require('./utils');
     
     module.exports = async (ctx) => {
         const { domain = '91porn.com' } = ctx.query;
         const siteUrl = `https://${domain}/index.php`;
         const { lang = 'en_US' } = ctx.params;
    +    domainValidation(domain, ctx);
     
         const response = await got.post(siteUrl, {
             form: {
    
  • lib/v2/91porn/utils.js+12 0 added
    @@ -0,0 +1,12 @@
    +const config = require('@/config').value;
    +const allowDomain = ['91porn.com', 'www.91porn.com', '0122.91p30.com', 'www.91zuixindizhi.com', 'w1218.91p46.com'];
    +
    +const domainValidation = (domain, ctx) => {
    +    if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.includes(domain)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
    +};
    +
    +module.exports = {
    +    domainValidation,
    +};
    
  • lib/v2/bendibao/news.js+4 0 modified
    @@ -2,9 +2,13 @@ const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const city = ctx.params.city;
    +    if (!isValidHost(city)) {
    +        throw Error('Invalid city');
    +    }
     
         const rootUrl = `http://${city}.bendibao.com`;
     
    
  • lib/v2/biquge/index.js+21 0 modified
    @@ -3,10 +3,31 @@ const cheerio = require('cheerio');
     const iconv = require('iconv-lite');
     const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
    +const config = require('@/config').value;
    +const allowHost = [
    +    'www.xbiquwx.la',
    +    'www.biqu5200.net',
    +    'www.xbiquge.so',
    +    'www.biqugeu.net',
    +    'www.b520.cc',
    +    'www.ahfgb.com',
    +    'www.ibiquge.la',
    +    'www.biquge.tv',
    +    'www.bswtan.com',
    +    'www.biquge.co',
    +    'www.bqzhh.com',
    +    'www.biqugse.com',
    +    'www.ibiquge.info',
    +    'www.ishuquge.com',
    +    'www.mayiwxw.com',
    +];
     
     module.exports = async (ctx) => {
         const rootUrl = ctx.path.split('/').slice(1, 4).join('/');
         const currentUrl = ctx.path.slice(1);
    +    if (!config.feature.allow_user_supply_unsafe_domain && !allowHost.includes(new URL(rootUrl).hostname)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         const response = await got({
             method: 'get',
    
  • lib/v2/btzj/index.js+6 1 modified
    @@ -4,10 +4,15 @@ const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const config = require('@/config').value;
    +const allowDomain = ['btbtt15.com'];
     
     module.exports = async (ctx) => {
         let category = ctx.params.category ?? '';
    -    let domain = ctx.query.domain ?? 'btbtt20.com';
    +    let domain = ctx.query.domain ?? 'btbtt15.com';
    +    if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.includes(new URL(domain).hostname)) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
         if (category === 'base') {
             category = '';
    
  • lib/v2/cnjxol/index.js+3 0 modified
    @@ -12,6 +12,9 @@ const categories = {
     module.exports = async (ctx) => {
         const category = ctx.params.category ?? 'jxrb';
         const id = ctx.params.id;
    +    if (!Object.keys(categories).includes(category)) {
    +        throw Error('Invalid category');
    +    }
     
         const rootUrl = `https://${category}.cnjxol.com`;
         const currentUrl = `${rootUrl}/${category}Paper/pc/layout`;
    
  • lib/v2/dut/index.js+4 0 modified
    @@ -3,9 +3,13 @@ const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
     const defaults = require('./defaults');
     const shortcuts = require('./shortcuts');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const site = ctx.params[0] ?? 'news';
    +    if (!isValidHost(site)) {
    +        throw Error('Invalid site');
    +    }
     
         let items;
         let category = ctx.params[1] ?? (defaults.hasOwnProperty(site) ? defaults[site] : '');
    
  • lib/v2/eagle/blog.js+6 2 modified
    @@ -1,16 +1,20 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
    -
    +const { isValidHost } = require('@/utils/valid-host');
     const cateList = ['all', 'design-resources', 'learn-design', 'inside-eagle'];
     
     module.exports = async (ctx) => {
         let cate = ctx.params.cate ?? 'all';
         let language = ctx.params.language ?? 'cn';
    -    if (cateList.indexOf(cate) === -1) {
    +    if (!isValidHost(cate) || !isValidHost(language)) {
    +        throw Error('Invalid host');
    +    }
    +    if (!cateList.includes(cate)) {
             language = cate;
             cate = 'all';
         }
    +
         const host = `https://${language}.eagle.cool`;
         const url = `${host}/blog/${cate === 'all' ? '' : cate}`;
     
    
  • lib/v2/eprice/rss.js+4 0 modified
    @@ -4,9 +4,13 @@ const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const allowRegion = ['tw', 'hk'];
     
     module.exports = async (ctx) => {
         const region = ctx.params.region ?? 'tw';
    +    if (!allowRegion.includes(region)) {
    +        throw Error('Invalid region');
    +    }
     
         const feed = await parser.parseURL(`https://www.eprice.com.${region}/news/rss.xml`);
     
    
  • lib/v2/ff14/ff14_global.js+5 0 modified
    @@ -2,11 +2,16 @@ const got = require('@/utils/got');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const lang = ctx.params.lang;
         const type = ctx.params.type ?? 'all';
     
    +    if (!isValidHost(lang)) {
    +        throw Error('Invalid lang');
    +    }
    +
         const response = await got({
             method: 'get',
             url: `https://lodestonenews.com/news/${type}?locale=${lang}`,
    
  • lib/v2/gamme/category.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const parser = require('@/utils/rss-parser');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const { domain = 'news', category } = ctx.params;
    +    if (!isValidHost(domain)) {
    +        throw Error('Invalid domain');
    +    }
         const baseUrl = `https://${domain}.gamme.com.tw`;
         const feed = await parser.parseURL(`${baseUrl + (category ? `/category/${category}` : '')}/feed`);
     
    
  • lib/v2/gamme/tag.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const { domain = 'news', tag } = ctx.params;
    +    if (!isValidHost(domain)) {
    +        throw Error('Invalid domain');
    +    }
         const baseUrl = `https://${domain}.gamme.com.tw`;
         const pageUrl = `${baseUrl}/tag/${tag}`;
     
    
  • lib/v2/gumroad/index.js+4 0 modified
    @@ -2,10 +2,14 @@ const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const username = ctx.params.username;
         const products = ctx.params.products;
    +    if (!isValidHost(username)) {
    +        throw Error('Invalid username');
    +    }
         const url = `https://${username}.gumroad.com/l/${products}`;
     
         const response = await got(url);
    
  • lib/v2/huanqiu/index.js+5 0 modified
    @@ -1,6 +1,7 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
    +const { isValidHost } = require('@/utils/valid-host');
     
     function getKeysRecursive(dic, key, attr, array) {
         Object.values(dic).forEach((v) => {
    @@ -15,6 +16,10 @@ function getKeysRecursive(dic, key, attr, array) {
     
     module.exports = async (ctx) => {
         const category = ctx.params.category ?? 'china';
    +    if (!isValidHost(category)) {
    +        throw Error('Invalid category');
    +    }
    +
         const host = 'https://' + category + '.huanqiu.com';
     
         const resp = await got({
    
  • lib/v2/itch/devlog.js+4 0 modified
    @@ -4,10 +4,14 @@ const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const user = ctx.params.user ?? '';
         const id = ctx.params.id ?? '';
    +    if (!isValidHost(user)) {
    +        throw Error('Invalid user');
    +    }
     
         const rootUrl = `https://${user}.itch.io/${id}/devlog`;
     
    
  • lib/v2/javbus/index.js+12 2 modified
    @@ -3,17 +3,27 @@ const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const config = require('@/config').value;
     
     const toSize = (raw) => {
         const matches = raw.match(/(\d+(\.\d+)?)(\w+)/);
         return matches[3] === 'GB' ? matches[1] * 1024 : matches[1];
     };
     
    +const allowDomain = ['javbus.com', 'javbus.org', 'javsee.icu', 'javsee.one'];
    +
     module.exports = async (ctx) => {
         const isWestern = /^\/western/.test(ctx.path);
    +    const domain = ctx.query.domain ?? 'javbus.com';
    +    const westernDomain = ctx.query.western_domain ?? 'javbus.org';
    +
    +    const rootUrl = `https://www.${domain}`;
    +    const westernUrl = `https://www.${westernDomain}`;
    +
    +    if (!config.feature.allow_user_supply_unsafe_domain && (!allowDomain.includes(new URL(domain).hostname) || !allowDomain.includes(new URL(westernDomain).hostname))) {
    +        ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +    }
     
    -    const rootUrl = `https://www.${ctx.query.domain ?? 'javbus.com'}`;
    -    const westernUrl = `https://www.${ctx.query.western_domain ?? 'javbus.org'}`;
         const currentUrl = `${isWestern ? westernUrl : rootUrl}${ctx.path.replace(/^\/western/, '').replace(/\/home/, '')}`;
     
         const response = await got({
    
  • lib/v2/javdb/utils.js+6 0 modified
    @@ -1,10 +1,16 @@
     const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
    +const config = require('@/config').value;
    +const allowDomain = ['javdb.com', 'javdb36.com', 'javdb007.com'];
     
     module.exports = {
         ProcessItems: async (ctx, currentUrl, title) => {
             const domain = ctx.query.domain ?? 'javdb.com';
    +        if (!config.feature.allow_user_supply_unsafe_domain && !allowDomain.includes(new URL(domain).hostname)) {
    +            ctx.throw(403, `This RSS is disabled unless 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' is set to 'true'.`);
    +        }
    +
             const rootUrl = `https://${domain}`;
     
             const response = await got({
    
  • lib/v2/lofter/user.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const { parseDate } = require('@/utils/parse-date');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const name = ctx.params.name ?? 'i';
         const limit = ctx.query.limit ? parseInt(ctx.query.limit) : '50';
    +    if (!isValidHost(name)) {
    +        throw Error('Invalid name');
    +    }
     
         const rootUrl = `${name}.lofter.com`;
     
    
  • lib/v2/mirror/index.js+4 1 modified
    @@ -4,10 +4,13 @@ const md = require('markdown-it')({
         html: true,
         linkify: true,
     });
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const id = ctx.params.id;
    -
    +    if (!id.endsWith('.eth') && !isValidHost(id)) {
    +        throw Error('Invalid id');
    +    }
         const rootUrl = 'https://mirror.xyz';
         const currentUrl = id.endsWith('.eth') ? `${rootUrl}/${id}` : `https://${id}.mirror.xyz`;
     
    
  • lib/v2/myfigurecollection/activity.js+5 0 modified
    @@ -4,6 +4,7 @@ const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const category = ctx.params.category ?? '-1';
    @@ -13,6 +14,10 @@ module.exports = async (ctx) => {
         const latestAlerts = ctx.params.latestAlerts ?? '1';
         const latestPictures = ctx.params.latestPictures ?? '1';
     
    +    if (language && !isValidHost(language)) {
    +        throw Error('Invalid language');
    +    }
    +
         const rootUrl = `https://${language === 'en' || language === '' ? '' : `${language}.`}myfigurecollection.net`;
         const currentUrl = `${rootUrl}/browse.v4.php?mode=activity&latestAdditions=${latestAdditions}&latestEdits=${latestEdits}&latestAlerts=${latestAlerts}&latestPictures=${latestPictures}&rootId=${category}`;
     
    
  • lib/v2/myfigurecollection/index.js+4 0 modified
    @@ -2,6 +2,7 @@ const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const { art } = require('@/utils/render');
     const path = require('path');
    +const { isValidHost } = require('@/utils/valid-host');
     
     const shortcuts = {
         potd: 'picture/browse/potd/',
    @@ -12,6 +13,9 @@ const shortcuts = {
     module.exports = async (ctx) => {
         const language = ctx.params.language ?? '';
         const category = ctx.params.category ?? 'figure';
    +    if (language && !isValidHost(language)) {
    +        throw Error('Invalid language');
    +    }
     
         const rootUrl = `https://${language === 'en' || language === '' ? '' : `${language}.`}myfigurecollection.net`;
         const currentUrl = `${rootUrl}/${shortcuts.hasOwnProperty(category) ? shortcuts[category] : category}`;
    
  • lib/v2/nikkei-cn/index.js+4 0 modified
    @@ -2,6 +2,7 @@ const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
    +const { isValidHost } = require('@/utils/valid-host');
     
     const cleanContent = (language, content) => {
         switch (language) {
    @@ -23,6 +24,9 @@ module.exports = async (ctx) => {
         const language = ctx.params.language ?? 'cn';
         const category = ctx.params.category ?? '';
         const type = ctx.params.type ?? '';
    +    if (!isValidHost(language)) {
    +        throw Error('Invalid language');
    +    }
     
         const rootUrl = `https://${language === 'zh' ? `zh.cn` : language}.nikkei.com`;
         const currentUrl = `${rootUrl}/${category ? (category === 'rss' ? 'rss.html' : `${category}${type ? `/${type}` : ''}.html`) : ''}`;
    
  • lib/v2/people/index.js+4 0 modified
    @@ -3,12 +3,16 @@ const cheerio = require('cheerio');
     const iconv = require('iconv-lite');
     const timezone = require('@/utils/timezone');
     const { parseDate } = require('@/utils/parse-date');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const site = ctx.params[0] ?? 'www';
         let category = ctx.params[1] ?? (site === 'www' ? '59476' : '');
         category = site === 'cpc' && category === '24h' ? '87228' : category;
     
    +    if (!isValidHost(site)) {
    +        throw Error('Invalid site');
    +    }
         const rootUrl = `http://${site}.people.com.cn`;
         const currentUrl = `${rootUrl}/GB/${category}/index.html`;
     
    
  • lib/v2/scitation/journal.js+4 0 modified
    @@ -1,12 +1,16 @@
     const cheerio = require('cheerio');
     const { puppeteerGet, renderDesc } = require('./utils');
     const config = require('@/config').value;
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const pub = ctx.params.pub;
         const jrn = ctx.params.jrn;
         const host = `https://${pub}.scitation.org`;
         const jrnlUrl = `${host}/toc/${jrn}/current?size=all`;
    +    if (!isValidHost(pub)) {
    +        throw Error('Invalid pub');
    +    }
     
         // use Puppeteer due to the obstacle by cloudflare challenge
         const browser = await require('@/utils/puppeteer')();
    
  • lib/v2/scitation/section.js+4 0 modified
    @@ -1,13 +1,17 @@
     const cheerio = require('cheerio');
     const { puppeteerGet, renderDesc } = require('./utils');
     const config = require('@/config').value;
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const pub = ctx.params.pub;
         const jrn = ctx.params.jrn;
         const sec = ctx.params.sec.split('+').join(' ');
         const host = `https://${pub}.scitation.org`;
         const jrnlUrl = `${host}/toc/${jrn}/current?size=all`;
    +    if (!isValidHost(pub)) {
    +        throw Error('Invalid pub');
    +    }
     
         // use Puppeteer due to the obstacle by cloudflare challenge
         const browser = await require('@/utils/puppeteer')();
    
  • lib/v2/solidot/main.js+5 0 modified
    @@ -6,9 +6,14 @@
     const got = require('@/utils/got'); // get web content
     const cheerio = require('cheerio'); // html parser
     const get_article = require('./_article');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const type = ctx.params.type ?? 'www';
    +    if (!isValidHost(type)) {
    +        throw Error('Invalid type');
    +    }
    +
         const base_url = `https://${type}.solidot.org`;
         const response = await got({
             method: 'get',
    
  • lib/v2/zcool/user.js+4 0 modified
    @@ -2,11 +2,15 @@ const got = require('@/utils/got');
     const cheerio = require('cheerio');
     const { parseDate } = require('@/utils/parse-date');
     const { extractArticle, extractWork } = require('./utils');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const { uid } = ctx.params;
         let pageUrl = `https://www.zcool.com.cn/u/${uid}`;
         if (isNaN(uid)) {
    +        if (!isValidHost(uid)) {
    +            throw Error('Invalid uid');
    +        }
             pageUrl = `https://${uid}.zcool.com.cn`;
         }
         const { data: response } = await got(pageUrl);
    
  • lib/v2/zhubai/index.js+4 0 modified
    @@ -1,9 +1,13 @@
     const got = require('@/utils/got');
     const { parseDate } = require('@/utils/parse-date');
    +const { isValidHost } = require('@/utils/valid-host');
     
     module.exports = async (ctx) => {
         const { id } = ctx.params;
         const limit = ctx.query.limit ? parseInt(ctx.query.limit) : 20;
    +    if (!isValidHost(id)) {
    +        throw Error('Invalid id');
    +    }
     
         const response = await got({
             method: 'get',
    
  • test/utils/valid-host.js+21 0 added
    @@ -0,0 +1,21 @@
    +const { isValidHost } = require('../../lib/utils/valid-host');
    +
    +describe('valid-host', () => {
    +    it('validate hostname', () => {
    +        expect(isValidHost()).toBe(false);
    +        expect(isValidHost(123)).toBe(false);
    +        expect(isValidHost('')).toBe(false);
    +        expect(isValidHost('subd0main')).toBe(true);
    +        expect(isValidHost('-subd0main')).toBe(false);
    +        expect(isValidHost('sub-d0main')).toBe(true);
    +        expect(isValidHost('subd0main-')).toBe(false);
    +        expect(isValidHost('sub.d0main')).toBe(false);
    +        expect(isValidHost('sub-.d0main')).toBe(false);
    +        expect(isValidHost('s')).toBe(true);
    +        expect(isValidHost('-')).toBe(false);
    +        expect(isValidHost('0')).toBe(true);
    +        expect(isValidHost('s-')).toBe(false);
    +        expect(isValidHost('s-u')).toBe(true);
    +        expect(isValidHost('su')).toBe(true);
    +    });
    +});
    

Vulnerability mechanics

Root cause

"User-supplied hostnames are directly interpolated into HTTP request URLs without validation, enabling Server-Side Request Forgery (SSRF)."

Attack vector

An attacker sends a crafted HTTP request to an affected RSSHub route, supplying a malicious value in a query parameter (e.g., `domain`, `western_domain`, `lang`, `city`, `cate`, `language`) or in the URL path. The server then constructs a URL using that untrusted input and makes an HTTP request to the attacker-controlled host. This Server-Side Request Forgery (SSRF) allows the attacker to probe internal network resources, access cloud metadata endpoints, or interact with other services that would otherwise be unreachable from the internet. No authentication is required, and the only precondition is that the target RSSHub instance has not set the `ALLOW_USER_SUPPLY_UNSAFE_DOMAIN` feature flag to `true`.

Affected code

The vulnerability spans multiple route handlers that construct URLs from user-supplied parameters without validating the hostname. Affected files include `lib/v2/biquge/index.js`, `lib/v2/javbus/index.js`, `lib/v2/91porn/utils.js`, `lib/v2/btzj/index.js`, `lib/v2/javdb/utils.js`, `lib/routes/weforum/report.js`, `lib/v2/eagle/blog.js`, and `lib/routes/ziroom/room.js`. These routes accept domain, language, city, or other parameters that are directly interpolated into HTTP request URLs without hostname validation [patch_id=1641118].

What the fix does

The patch introduces two complementary defenses. First, a new `isValidHost` utility function (`lib/utils/valid-host.js`) validates that a subdomain string contains only allowed characters (alphanumeric and hyphens, not starting or ending with a hyphen). This function is applied in `lib/routes/weforum/report.js`, `lib/v2/eagle/blog.js`, and `lib/routes/ziroom/room.js` to reject obviously malicious hostnames before they are used in URL construction. Second, for routes that accept arbitrary domains (biquge, javbus, 91porn, btzj, javdb), the patch introduces an allowlist of known-good domains and a feature flag (`config.feature.allow_user_supply_unsafe_domain`). Unless the administrator explicitly sets this flag to `true`, the server throws a 403 error when the supplied domain is not in the allowlist. Together, these changes prevent an attacker from directing the server to make requests to arbitrary external or internal hosts [patch_id=1641118].

Preconditions

  • configThe RSSHub instance must not have the 'ALLOW_USER_SUPPLY_UNSAFE_DOMAIN' configuration flag set to 'true' (the default is false/undefined).
  • networkThe attacker must be able to send HTTP requests to the RSSHub server (no authentication required).
  • inputThe attacker supplies a malicious hostname via a query parameter (e.g., domain, western_domain, lang) or URL path segment.

Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.