From 52cce0ee21ad867a694d39430012eec0a061edc1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 2 Jun 2024 20:11:03 -0700 Subject: [PATCH] Merge pull request from GHSA-24m5-7vjx-9x37 * Restrict emby endpoints and proxy segments * Dont allow path traversal in segments * Restrict qbittorrent proxy endpoints * Restrict npm proxy endpoints * Restrict flood proxy endpoints * Restrict tdarr proxy endpoints * Restrict xteve proxy endpoints * Restrict transmission proxy endpoints * disallow non-mapped endpoints this change drops all requests that have un-mapped endpoint queries allowedEndpoints is added as a method to pass proxy requests via a regex on the endpoint most widgets with custom proxies use either no endpoint, or a static one Co-Authored-By: Ben Phelps --- src/pages/api/services/proxy.js | 23 ++++++++++++++++++++++- src/utils/proxy/api-helpers.js | 15 +++++---------- src/widgets/emby/component.jsx | 14 +++++++++----- src/widgets/emby/widget.js | 12 ++++++++---- src/widgets/flood/widget.js | 6 ++++++ src/widgets/fritzbox/widget.js | 1 + src/widgets/gamedig/widget.js | 1 + src/widgets/glances/widget.js | 1 + src/widgets/minecraft/widget.js | 1 + src/widgets/npm/component.jsx | 2 +- src/widgets/npm/widget.js | 6 ++++++ src/widgets/nzbget/widget.js | 1 + src/widgets/qbittorrent/component.jsx | 2 +- src/widgets/qbittorrent/widget.js | 6 ++++++ src/widgets/qnap/widget.js | 1 + src/widgets/swagdashboard/widget.js | 1 + src/widgets/tdarr/proxy.js | 4 ++-- src/widgets/transmission/proxy.js | 4 ++-- src/widgets/urbackup/widget.js | 1 + src/widgets/xteve/component.jsx | 2 +- src/widgets/xteve/proxy.js | 4 ++-- src/widgets/xteve/widget.js | 6 ------ 22 files changed, 79 insertions(+), 35 deletions(-) diff --git a/src/pages/api/services/proxy.js b/src/pages/api/services/proxy.js index be4a96a67..9347c4eb2 100644 --- a/src/pages/api/services/proxy.js +++ b/src/pages/api/services/proxy.js @@ -18,6 +18,11 @@ export default async function handler(req, res) { const serviceProxyHandler = widget.proxyHandler || genericProxyHandler; if (serviceProxyHandler instanceof Function) { + // quick return for no endpoint services + if (!req.query.endpoint) { + return serviceProxyHandler(req, res); + } + // map opaque endpoints to their actual endpoint if (widget?.mappings) { const mapping = widget?.mappings?.[req.query.endpoint]; @@ -38,6 +43,15 @@ export default async function handler(req, res) { if (req.query.segments) { const segments = JSON.parse(req.query.segments); + for (const key in segments) { + if (!mapping.segments.includes(key)) { + logger.debug("Unsupported segment: %s", key); + return res.status(403).json({ error: "Unsupported segment" }); + } else if (segments[key].includes("/")) { + logger.debug("Unsupported segment value: %s", segments[key]); + return res.status(403).json({ error: "Unsupported segment value" }); + } + } req.query.endpoint = formatApiCall(endpoint, segments); } @@ -66,7 +80,14 @@ export default async function handler(req, res) { return serviceProxyHandler(req, res, map); } - return serviceProxyHandler(req, res); + if (widget.allowedEndpoints instanceof RegExp) { + if (widget.allowedEndpoints.test(req.query.endpoint)) { + return serviceProxyHandler(req, res); + } + } + + logger.debug("Unmapped proxy request."); + return res.status(403).json({ error: "Unmapped proxy request." }); } logger.debug("Unknown proxy service type: %s", type); diff --git a/src/utils/proxy/api-helpers.js b/src/utils/proxy/api-helpers.js index ffd2f63bc..dceea3c4f 100644 --- a/src/utils/proxy/api-helpers.js +++ b/src/utils/proxy/api-helpers.js @@ -8,22 +8,16 @@ export function formatApiCall(url, args) { return url.replace(/\/+$/, "").replace(find, replace).replace(find, replace); } -function getURLSearchParams(widget, endpoint) { +export function getURLSearchParams(widget, endpoint) { const params = new URLSearchParams({ type: widget.type, group: widget.service_group, service: widget.service_name, - endpoint, }); - return params; -} - -export function formatProxyUrlWithSegments(widget, endpoint, segments) { - const params = getURLSearchParams(widget, endpoint); - if (segments) { - params.append("segments", JSON.stringify(segments)); + if (endpoint) { + params.append("endpoint", endpoint); } - return `/api/services/proxy?${params.toString()}`; + return params; } export function formatProxyUrl(widget, endpoint, queryParams) { @@ -59,6 +53,7 @@ export function sanitizeErrorURL(errorURL) { const url = new URL(errorURL); ["apikey", "api_key", "token", "t", "access_token", "auth"].forEach((key) => { if (url.searchParams.has(key)) url.searchParams.set(key, "***"); + if (url.hash.includes(key)) url.hash = url.hash.replace(new RegExp(`${key}=[^&]+`), `${key}=***`); }); return url.toString(); } diff --git a/src/widgets/emby/component.jsx b/src/widgets/emby/component.jsx index 9084cbac2..090a9c3f4 100644 --- a/src/widgets/emby/component.jsx +++ b/src/widgets/emby/component.jsx @@ -4,7 +4,7 @@ import { MdOutlineSmartDisplay } from "react-icons/md"; import Block from "components/services/widget/block"; import Container from "components/services/widget/container"; -import { formatProxyUrlWithSegments } from "utils/proxy/api-helpers"; +import { getURLSearchParams } from "utils/proxy/api-helpers"; import useWidgetAPI from "utils/proxy/use-widget-api"; function ticksToTime(ticks) { @@ -217,10 +217,14 @@ export default function Component({ service }) { }); async function handlePlayCommand(session, command) { - const url = formatProxyUrlWithSegments(widget, "PlayControl", { - sessionId: session.Id, - command, - }); + const params = getURLSearchParams(widget, command); + params.append( + "segments", + JSON.stringify({ + sessionId: session.Id, + }), + ); + const url = `/api/services/proxy?${params.toString()}`; await fetch(url).then(() => { sessionMutate(); }); diff --git a/src/widgets/emby/widget.js b/src/widgets/emby/widget.js index 1dc009b2a..3b04f59fb 100644 --- a/src/widgets/emby/widget.js +++ b/src/widgets/emby/widget.js @@ -10,12 +10,16 @@ const widget = { }, Count: { endpoint: "Items/Counts", - segments: ["MovieCount", "SeriesCount", "EpisodeCount", "SongCount"], }, - PlayControl: { + Unpause: { method: "POST", - endpoint: "Sessions/{sessionId}/Playing/{command}", - segments: ["sessionId", "command"], + endpoint: "Sessions/{sessionId}/Playing/Unpause", + segments: ["sessionId"], + }, + Pause: { + method: "POST", + endpoint: "Sessions/{sessionId}/Playing/Pause", + segments: ["sessionId"], }, }, }; diff --git a/src/widgets/flood/widget.js b/src/widgets/flood/widget.js index 027ff344b..13413cf44 100644 --- a/src/widgets/flood/widget.js +++ b/src/widgets/flood/widget.js @@ -2,6 +2,12 @@ import floodProxyHandler from "./proxy"; const widget = { proxyHandler: floodProxyHandler, + + mappings: { + torrents: { + endpoint: "torrents", + }, + }, }; export default widget; diff --git a/src/widgets/fritzbox/widget.js b/src/widgets/fritzbox/widget.js index 131938210..32e8a5c27 100644 --- a/src/widgets/fritzbox/widget.js +++ b/src/widgets/fritzbox/widget.js @@ -2,6 +2,7 @@ import fritzboxProxyHandler from "./proxy"; const widget = { proxyHandler: fritzboxProxyHandler, + allowedEndpoints: /status/, }; export default widget; diff --git a/src/widgets/gamedig/widget.js b/src/widgets/gamedig/widget.js index 6ccfa123a..0f888b43d 100644 --- a/src/widgets/gamedig/widget.js +++ b/src/widgets/gamedig/widget.js @@ -2,6 +2,7 @@ import gamedigProxyHandler from "./proxy"; const widget = { proxyHandler: gamedigProxyHandler, + allowedEndpoints: /status/, }; export default widget; diff --git a/src/widgets/glances/widget.js b/src/widgets/glances/widget.js index 3357cf28e..e018ae39b 100644 --- a/src/widgets/glances/widget.js +++ b/src/widgets/glances/widget.js @@ -3,6 +3,7 @@ import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; const widget = { api: "{url}/api/{endpoint}", proxyHandler: credentialedProxyHandler, + allowedEndpoints: /\d\/quicklook|diskio|fs|gpu|system|mem|network|processlist|sensors/, }; export default widget; diff --git a/src/widgets/minecraft/widget.js b/src/widgets/minecraft/widget.js index f8a81bfb2..fbe413b75 100644 --- a/src/widgets/minecraft/widget.js +++ b/src/widgets/minecraft/widget.js @@ -2,6 +2,7 @@ import minecraftProxyHandler from "./proxy"; const widget = { proxyHandler: minecraftProxyHandler, + allowedEndpoints: /status/, }; export default widget; diff --git a/src/widgets/npm/component.jsx b/src/widgets/npm/component.jsx index 377122664..06ac91ebe 100644 --- a/src/widgets/npm/component.jsx +++ b/src/widgets/npm/component.jsx @@ -5,7 +5,7 @@ import useWidgetAPI from "utils/proxy/use-widget-api"; export default function Component({ service }) { const { widget } = service; - const { data: infoData, error: infoError } = useWidgetAPI(widget, "nginx/proxy-hosts"); + const { data: infoData, error: infoError } = useWidgetAPI(widget, "hosts"); if (infoError) { return ; diff --git a/src/widgets/npm/widget.js b/src/widgets/npm/widget.js index 652cb4a25..24b3ce029 100644 --- a/src/widgets/npm/widget.js +++ b/src/widgets/npm/widget.js @@ -3,6 +3,12 @@ import npmProxyHandler from "./proxy"; const widget = { api: "{url}/api/{endpoint}", proxyHandler: npmProxyHandler, + + mappings: { + hosts: { + endpoint: "nginx/proxy-hosts", + }, + }, }; export default widget; diff --git a/src/widgets/nzbget/widget.js b/src/widgets/nzbget/widget.js index 841fb66c0..79ca1807d 100644 --- a/src/widgets/nzbget/widget.js +++ b/src/widgets/nzbget/widget.js @@ -3,6 +3,7 @@ import jsonrpcProxyHandler from "utils/proxy/handlers/jsonrpc"; const widget = { api: "{url}/jsonrpc", proxyHandler: jsonrpcProxyHandler, + allowedEndpoints: /status/, }; export default widget; diff --git a/src/widgets/qbittorrent/component.jsx b/src/widgets/qbittorrent/component.jsx index 615709ea6..e88b26227 100644 --- a/src/widgets/qbittorrent/component.jsx +++ b/src/widgets/qbittorrent/component.jsx @@ -9,7 +9,7 @@ export default function Component({ service }) { const { widget } = service; - const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents/info"); + const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents"); if (torrentError) { return ; diff --git a/src/widgets/qbittorrent/widget.js b/src/widgets/qbittorrent/widget.js index 1e8348b33..182ac9d1b 100644 --- a/src/widgets/qbittorrent/widget.js +++ b/src/widgets/qbittorrent/widget.js @@ -2,6 +2,12 @@ import qbittorrentProxyHandler from "./proxy"; const widget = { proxyHandler: qbittorrentProxyHandler, + + mappings: { + torrents: { + endpoint: "torrents/info", + }, + }, }; export default widget; diff --git a/src/widgets/qnap/widget.js b/src/widgets/qnap/widget.js index ebaf93c9d..1069fa9a4 100644 --- a/src/widgets/qnap/widget.js +++ b/src/widgets/qnap/widget.js @@ -3,6 +3,7 @@ import qnapProxyHandler from "./proxy"; const widget = { api: "{url}", proxyHandler: qnapProxyHandler, + allowedEndpoints: /status/, }; export default widget; diff --git a/src/widgets/swagdashboard/widget.js b/src/widgets/swagdashboard/widget.js index 626586fe1..7067e55d2 100644 --- a/src/widgets/swagdashboard/widget.js +++ b/src/widgets/swagdashboard/widget.js @@ -3,6 +3,7 @@ import genericProxyHandler from "utils/proxy/handlers/generic"; const widget = { api: "{url}/?stats=true", proxyHandler: genericProxyHandler, + allowedEndpoints: /overview/, }; export default widget; diff --git a/src/widgets/tdarr/proxy.js b/src/widgets/tdarr/proxy.js index a1ebc149b..898082f40 100644 --- a/src/widgets/tdarr/proxy.js +++ b/src/widgets/tdarr/proxy.js @@ -8,7 +8,7 @@ const proxyName = "tdarrProxyHandler"; const logger = createLogger(proxyName); export default async function tdarrProxyHandler(req, res) { - const { group, service, endpoint } = req.query; + const { group, service } = req.query; if (!group || !service) { logger.debug("Invalid or missing service '%s' or group '%s'", service, group); @@ -22,7 +22,7 @@ export default async function tdarrProxyHandler(req, res) { return res.status(400).json({ error: "Invalid proxy service type" }); } - const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget })); + const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint: undefined, ...widget })); const [status, contentType, data] = await httpProxy(url, { method: "POST", diff --git a/src/widgets/transmission/proxy.js b/src/widgets/transmission/proxy.js index f12d2a0c0..823def054 100644 --- a/src/widgets/transmission/proxy.js +++ b/src/widgets/transmission/proxy.js @@ -11,7 +11,7 @@ const headerCacheKey = `${proxyName}__headers`; const logger = createLogger(proxyName); export default async function transmissionProxyHandler(req, res) { - const { group, service, endpoint } = req.query; + const { group, service } = req.query; if (!group || !service) { logger.debug("Invalid or missing service '%s' or group '%s'", service, group); @@ -35,7 +35,7 @@ export default async function transmissionProxyHandler(req, res) { const api = `${widget.url}${widget.rpcUrl || widgets[widget.type].rpcUrl}rpc`; - const url = new URL(formatApiCall(api, { endpoint, ...widget })); + const url = new URL(formatApiCall(api, { endpoint: undefined, ...widget })); const csrfHeaderName = "x-transmission-session-id"; const method = "POST"; diff --git a/src/widgets/urbackup/widget.js b/src/widgets/urbackup/widget.js index 5eac66d07..96c52296e 100644 --- a/src/widgets/urbackup/widget.js +++ b/src/widgets/urbackup/widget.js @@ -2,6 +2,7 @@ import urbackupProxyHandler from "./proxy"; const widget = { proxyHandler: urbackupProxyHandler, + allowedEndpoints: /status/, }; export default widget; diff --git a/src/widgets/xteve/component.jsx b/src/widgets/xteve/component.jsx index 75629909a..84a617c2e 100644 --- a/src/widgets/xteve/component.jsx +++ b/src/widgets/xteve/component.jsx @@ -9,7 +9,7 @@ export default function Component({ service }) { const { widget } = service; - const { data: xteveData, error: xteveError } = useWidgetAPI(widget, "api"); + const { data: xteveData, error: xteveError } = useWidgetAPI(widget); if (xteveError) { return ; diff --git a/src/widgets/xteve/proxy.js b/src/widgets/xteve/proxy.js index a8b1c80f2..421f2b499 100644 --- a/src/widgets/xteve/proxy.js +++ b/src/widgets/xteve/proxy.js @@ -7,7 +7,7 @@ import getServiceWidget from "utils/config/service-helpers"; const logger = createLogger("xteveProxyHandler"); export default async function xteveProxyHandler(req, res) { - const { group, service, endpoint } = req.query; + const { group, service } = req.query; if (!group || !service) { return res.status(400).json({ error: "Invalid proxy service type" }); @@ -19,7 +19,7 @@ export default async function xteveProxyHandler(req, res) { return res.status(403).json({ error: "Service does not support API calls" }); } - const url = formatApiCall(api, { endpoint, ...widget }); + const url = formatApiCall(api, { endpoint: "api/", ...widget }); const method = "POST"; const payload = { cmd: "status" }; diff --git a/src/widgets/xteve/widget.js b/src/widgets/xteve/widget.js index e7998e2e7..72c62b253 100644 --- a/src/widgets/xteve/widget.js +++ b/src/widgets/xteve/widget.js @@ -3,12 +3,6 @@ import xteveProxyHandler from "./proxy"; const widget = { api: "{url}/{endpoint}", proxyHandler: xteveProxyHandler, - - mappings: { - api: { - endpoint: "api/", - }, - }, }; export default widget;