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 <ben@phelps.io>
pull/3566/head
shamoon 6 months ago
parent 8823b04291
commit 52cce0ee21

@ -18,6 +18,11 @@ export default async function handler(req, res) {
const serviceProxyHandler = widget.proxyHandler || genericProxyHandler; const serviceProxyHandler = widget.proxyHandler || genericProxyHandler;
if (serviceProxyHandler instanceof Function) { 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 // map opaque endpoints to their actual endpoint
if (widget?.mappings) { if (widget?.mappings) {
const mapping = widget?.mappings?.[req.query.endpoint]; const mapping = widget?.mappings?.[req.query.endpoint];
@ -38,6 +43,15 @@ export default async function handler(req, res) {
if (req.query.segments) { if (req.query.segments) {
const segments = JSON.parse(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); 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, 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); logger.debug("Unknown proxy service type: %s", type);

@ -8,22 +8,16 @@ export function formatApiCall(url, args) {
return url.replace(/\/+$/, "").replace(find, replace).replace(find, replace); return url.replace(/\/+$/, "").replace(find, replace).replace(find, replace);
} }
function getURLSearchParams(widget, endpoint) { export function getURLSearchParams(widget, endpoint) {
const params = new URLSearchParams({ const params = new URLSearchParams({
type: widget.type, type: widget.type,
group: widget.service_group, group: widget.service_group,
service: widget.service_name, service: widget.service_name,
endpoint,
}); });
return params; if (endpoint) {
} params.append("endpoint", endpoint);
export function formatProxyUrlWithSegments(widget, endpoint, segments) {
const params = getURLSearchParams(widget, endpoint);
if (segments) {
params.append("segments", JSON.stringify(segments));
} }
return `/api/services/proxy?${params.toString()}`; return params;
} }
export function formatProxyUrl(widget, endpoint, queryParams) { export function formatProxyUrl(widget, endpoint, queryParams) {
@ -59,6 +53,7 @@ export function sanitizeErrorURL(errorURL) {
const url = new URL(errorURL); const url = new URL(errorURL);
["apikey", "api_key", "token", "t", "access_token", "auth"].forEach((key) => { ["apikey", "api_key", "token", "t", "access_token", "auth"].forEach((key) => {
if (url.searchParams.has(key)) url.searchParams.set(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(); return url.toString();
} }

@ -4,7 +4,7 @@ import { MdOutlineSmartDisplay } from "react-icons/md";
import Block from "components/services/widget/block"; import Block from "components/services/widget/block";
import Container from "components/services/widget/container"; 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"; import useWidgetAPI from "utils/proxy/use-widget-api";
function ticksToTime(ticks) { function ticksToTime(ticks) {
@ -217,10 +217,14 @@ export default function Component({ service }) {
}); });
async function handlePlayCommand(session, command) { async function handlePlayCommand(session, command) {
const url = formatProxyUrlWithSegments(widget, "PlayControl", { const params = getURLSearchParams(widget, command);
sessionId: session.Id, params.append(
command, "segments",
}); JSON.stringify({
sessionId: session.Id,
}),
);
const url = `/api/services/proxy?${params.toString()}`;
await fetch(url).then(() => { await fetch(url).then(() => {
sessionMutate(); sessionMutate();
}); });

@ -10,12 +10,16 @@ const widget = {
}, },
Count: { Count: {
endpoint: "Items/Counts", endpoint: "Items/Counts",
segments: ["MovieCount", "SeriesCount", "EpisodeCount", "SongCount"],
}, },
PlayControl: { Unpause: {
method: "POST", method: "POST",
endpoint: "Sessions/{sessionId}/Playing/{command}", endpoint: "Sessions/{sessionId}/Playing/Unpause",
segments: ["sessionId", "command"], segments: ["sessionId"],
},
Pause: {
method: "POST",
endpoint: "Sessions/{sessionId}/Playing/Pause",
segments: ["sessionId"],
}, },
}, },
}; };

@ -2,6 +2,12 @@ import floodProxyHandler from "./proxy";
const widget = { const widget = {
proxyHandler: floodProxyHandler, proxyHandler: floodProxyHandler,
mappings: {
torrents: {
endpoint: "torrents",
},
},
}; };
export default widget; export default widget;

@ -2,6 +2,7 @@ import fritzboxProxyHandler from "./proxy";
const widget = { const widget = {
proxyHandler: fritzboxProxyHandler, proxyHandler: fritzboxProxyHandler,
allowedEndpoints: /status/,
}; };
export default widget; export default widget;

@ -2,6 +2,7 @@ import gamedigProxyHandler from "./proxy";
const widget = { const widget = {
proxyHandler: gamedigProxyHandler, proxyHandler: gamedigProxyHandler,
allowedEndpoints: /status/,
}; };
export default widget; export default widget;

@ -3,6 +3,7 @@ import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = { const widget = {
api: "{url}/api/{endpoint}", api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler, proxyHandler: credentialedProxyHandler,
allowedEndpoints: /\d\/quicklook|diskio|fs|gpu|system|mem|network|processlist|sensors/,
}; };
export default widget; export default widget;

@ -2,6 +2,7 @@ import minecraftProxyHandler from "./proxy";
const widget = { const widget = {
proxyHandler: minecraftProxyHandler, proxyHandler: minecraftProxyHandler,
allowedEndpoints: /status/,
}; };
export default widget; export default widget;

@ -5,7 +5,7 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) { export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: infoData, error: infoError } = useWidgetAPI(widget, "nginx/proxy-hosts"); const { data: infoData, error: infoError } = useWidgetAPI(widget, "hosts");
if (infoError) { if (infoError) {
return <Container service={service} error={infoError} />; return <Container service={service} error={infoError} />;

@ -3,6 +3,12 @@ import npmProxyHandler from "./proxy";
const widget = { const widget = {
api: "{url}/api/{endpoint}", api: "{url}/api/{endpoint}",
proxyHandler: npmProxyHandler, proxyHandler: npmProxyHandler,
mappings: {
hosts: {
endpoint: "nginx/proxy-hosts",
},
},
}; };
export default widget; export default widget;

@ -3,6 +3,7 @@ import jsonrpcProxyHandler from "utils/proxy/handlers/jsonrpc";
const widget = { const widget = {
api: "{url}/jsonrpc", api: "{url}/jsonrpc",
proxyHandler: jsonrpcProxyHandler, proxyHandler: jsonrpcProxyHandler,
allowedEndpoints: /status/,
}; };
export default widget; export default widget;

@ -9,7 +9,7 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents/info"); const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents");
if (torrentError) { if (torrentError) {
return <Container service={service} error={torrentError} />; return <Container service={service} error={torrentError} />;

@ -2,6 +2,12 @@ import qbittorrentProxyHandler from "./proxy";
const widget = { const widget = {
proxyHandler: qbittorrentProxyHandler, proxyHandler: qbittorrentProxyHandler,
mappings: {
torrents: {
endpoint: "torrents/info",
},
},
}; };
export default widget; export default widget;

@ -3,6 +3,7 @@ import qnapProxyHandler from "./proxy";
const widget = { const widget = {
api: "{url}", api: "{url}",
proxyHandler: qnapProxyHandler, proxyHandler: qnapProxyHandler,
allowedEndpoints: /status/,
}; };
export default widget; export default widget;

@ -3,6 +3,7 @@ import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = { const widget = {
api: "{url}/?stats=true", api: "{url}/?stats=true",
proxyHandler: genericProxyHandler, proxyHandler: genericProxyHandler,
allowedEndpoints: /overview/,
}; };
export default widget; export default widget;

@ -8,7 +8,7 @@ const proxyName = "tdarrProxyHandler";
const logger = createLogger(proxyName); const logger = createLogger(proxyName);
export default async function tdarrProxyHandler(req, res) { export default async function tdarrProxyHandler(req, res) {
const { group, service, endpoint } = req.query; const { group, service } = req.query;
if (!group || !service) { if (!group || !service) {
logger.debug("Invalid or missing service '%s' or group '%s'", service, group); 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" }); 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, { const [status, contentType, data] = await httpProxy(url, {
method: "POST", method: "POST",

@ -11,7 +11,7 @@ const headerCacheKey = `${proxyName}__headers`;
const logger = createLogger(proxyName); const logger = createLogger(proxyName);
export default async function transmissionProxyHandler(req, res) { export default async function transmissionProxyHandler(req, res) {
const { group, service, endpoint } = req.query; const { group, service } = req.query;
if (!group || !service) { if (!group || !service) {
logger.debug("Invalid or missing service '%s' or group '%s'", service, group); 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 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 csrfHeaderName = "x-transmission-session-id";
const method = "POST"; const method = "POST";

@ -2,6 +2,7 @@ import urbackupProxyHandler from "./proxy";
const widget = { const widget = {
proxyHandler: urbackupProxyHandler, proxyHandler: urbackupProxyHandler,
allowedEndpoints: /status/,
}; };
export default widget; export default widget;

@ -9,7 +9,7 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: xteveData, error: xteveError } = useWidgetAPI(widget, "api"); const { data: xteveData, error: xteveError } = useWidgetAPI(widget);
if (xteveError) { if (xteveError) {
return <Container service={service} error={xteveError} />; return <Container service={service} error={xteveError} />;

@ -7,7 +7,7 @@ import getServiceWidget from "utils/config/service-helpers";
const logger = createLogger("xteveProxyHandler"); const logger = createLogger("xteveProxyHandler");
export default async function xteveProxyHandler(req, res) { export default async function xteveProxyHandler(req, res) {
const { group, service, endpoint } = req.query; const { group, service } = req.query;
if (!group || !service) { if (!group || !service) {
return res.status(400).json({ error: "Invalid proxy service type" }); 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" }); 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 method = "POST";
const payload = { cmd: "status" }; const payload = { cmd: "status" };

@ -3,12 +3,6 @@ import xteveProxyHandler from "./proxy";
const widget = { const widget = {
api: "{url}/{endpoint}", api: "{url}/{endpoint}",
proxyHandler: xteveProxyHandler, proxyHandler: xteveProxyHandler,
mappings: {
api: {
endpoint: "api/",
},
},
}; };
export default widget; export default widget;

Loading…
Cancel
Save