diff --git a/.github/workflows/repeat-recent-requests.yml b/.github/workflows/repeat-recent-requests.yml index 39220a8c91bcd..536199b728b70 100644 --- a/.github/workflows/repeat-recent-requests.yml +++ b/.github/workflows/repeat-recent-requests.yml @@ -13,6 +13,8 @@ on: - cron: "15,45 * * * *" workflow_dispatch: +permissions: {} + jobs: triggerRepeatRecent: if: | diff --git a/packages/core/src/api/gist.js b/packages/core/src/api/gist.js index 0cb9610262ca8..8d5a28e11836f 100644 --- a/packages/core/src/api/gist.js +++ b/packages/core/src/api/gist.js @@ -1,4 +1,5 @@ import { renderGistCard } from "../cards/gist.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -26,6 +27,23 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", diff --git a/packages/core/src/api/index.js b/packages/core/src/api/index.js index 4751560082eab..87f6bfd9056cf 100644 --- a/packages/core/src/api/index.js +++ b/packages/core/src/api/index.js @@ -1,4 +1,5 @@ import { renderStatsCard } from "../cards/stats.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -44,6 +45,24 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + ring_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", diff --git a/packages/core/src/api/pin.js b/packages/core/src/api/pin.js index 118aa70cc2204..fe59500dfe228 100644 --- a/packages/core/src/api/pin.js +++ b/packages/core/src/api/pin.js @@ -1,4 +1,5 @@ import { renderRepoCard } from "../cards/repo.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -34,6 +35,23 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", diff --git a/packages/core/src/api/top-langs.js b/packages/core/src/api/top-langs.js index 46f6633c013e6..c4599351d9816 100644 --- a/packages/core/src/api/top-langs.js +++ b/packages/core/src/api/top-langs.js @@ -1,4 +1,5 @@ import { renderTopLanguages } from "../cards/top-languages.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -38,6 +39,23 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + text_color, + bg_color, + prog_bar_bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", diff --git a/packages/core/src/api/wakatime.js b/packages/core/src/api/wakatime.js index 19307f104aaf8..80a5421d4de61 100644 --- a/packages/core/src/api/wakatime.js +++ b/packages/core/src/api/wakatime.js @@ -1,4 +1,5 @@ import { renderWakatimeCard } from "../cards/wakatime.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -32,6 +33,23 @@ export default async ({ display_format, disable_animations, }) => { + const invalidColorInput = findInvalidColor({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index a352f357c1ea5..6c9a6c86790fb 100644 --- a/packages/core/src/cards/repo.js +++ b/packages/core/src/cards/repo.js @@ -1,6 +1,6 @@ import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; -import { getCardColors } from "../common/color.js"; +import { getCardColors, isValidHexColor } from "../common/color.js"; import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; import { encodeHTML } from "../common/html.js"; import { icons } from "../common/icons.js"; @@ -32,20 +32,29 @@ const DESCRIPTION_MAX_LINES = 3; * @param {string} textColor The color of the text. * @returns {string} Wrapped repo description SVG object. */ -const getBadgeSVG = (label, textColor, xOffset = 0) => ` - - - - ${label} - - -`; +const getBadgeSVG = (label, textColor, xOffset = 0) => { + if (!isValidHexColor(textColor, true)) { + throw new Error(`Invalid text color: "${textColor}"`); + } + if (!Number.isFinite(xOffset)) { + throw new Error(`Invalid xOffset: "${xOffset}"`); + } + + return ` + + + + ${encodeHTML(label)} + + + `; +}; /** * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data. @@ -110,6 +119,7 @@ const renderRepoCard = (repo, options = {}) => { }); let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], [])); + const encodedUsername = encodeURIComponent(username); const STATS = {}; if (show.includes("prs_authored")) { STATS.prs_authored = { @@ -117,7 +127,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.prs-authored"), value: totalPRsAuthored, id: "prs_authored", - link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`, + link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=pullrequests`, }; } if (show.includes("prs_commented")) { @@ -126,7 +136,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.prs-commented"), value: totalPRsCommented, id: "prs_commented", - link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`, + link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`, }; } if (show.includes("prs_reviewed")) { @@ -135,7 +145,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.prs-reviewed"), value: totalPRsReviewed, id: "prs_reviewed", - link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`, + link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`, }; } if (show.includes("issues_authored")) { @@ -144,7 +154,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.issues-authored"), value: totalIssuesAuthored, id: "issues_authored", - link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`, + link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=issues`, }; } if (show.includes("issues_commented")) { @@ -153,7 +163,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.issues-commented"), value: totalIssuesCommented, id: "issues_commented", - link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`, + link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=issues`, }; } diff --git a/packages/core/src/cards/stats.js b/packages/core/src/cards/stats.js index cbaf2b61f3e43..cd65180eed967 100644 --- a/packages/core/src/cards/stats.js +++ b/packages/core/src/cards/stats.js @@ -3,6 +3,7 @@ import { I18n } from "../common/I18n.js"; import { getCardColors } from "../common/color.js"; import { CustomError } from "../common/error.js"; import { kFormatter } from "../common/fmt.js"; +import { encodeHTML } from "../common/html.js"; import { icons, rankIcon } from "../common/icons.js"; import { buildSearchFilter, clampValue } from "../common/ops.js"; import { flexLayout, measureText } from "../common/render.js"; @@ -52,8 +53,10 @@ const LONG_LOCALES = [ /** * Create a stats card text item. * + * The caller must ensure that the passed `icon` and `link` are properly sanitized! + * * @param {object} params Object that contains the createTextNode parameters. - * @param {string} params.icon The icon to display. + * @param {string} params.icon The sanitized icon to display. * @param {string} params.label The label to display. * @param {number} params.value The value to display. * @param {string} params.id The id of the stat. @@ -64,7 +67,7 @@ const LONG_LOCALES = [ * @param {boolean} params.bold Whether to bold the label. * @param {string} params.numberFormat The format of numbers on card. * @param {number=} params.numberPrecision The precision of numbers on card. - * @param {string} params.link Url to link to. + * @param {string} params.link Sanitized url to link to. * @param {number} params.labelXOffset horizontal offset for label. * @returns {string} The stats card text item SVG object. */ @@ -83,6 +86,16 @@ const createTextNode = ({ link, labelXOffset = 25, }) => { + if (!Number.isFinite(labelXOffset)) { + throw new Error(`Invalid labelXOffset: "${labelXOffset}"`); + } + if (!Number.isFinite(shiftValuePos)) { + throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`); + } + if (!Number.isFinite(index)) { + throw new Error(`Invalid index: "${index}"`); + } + const precision = typeof numberPrecision === "number" && !isNaN(numberPrecision) ? clampValue(numberPrecision, 0, 2) @@ -109,7 +122,7 @@ const createTextNode = ({ ${iconSvg} ${label}: + }" ${labelOffset} y="12.5">${encodeHTML(label)}: - ${name} - ${hideValues ? "" : `${displayValue}`} + ${encodeHTML(name)} + ${hideValues ? "" : `${encodeHTML(displayValue)}`} ${createProgressNode({ x: 0, y: 25, @@ -285,11 +290,15 @@ const createCompactLangNode = ({ const staggerDelay = (index + 3) * 150; const color = lang.color || "#858585"; + if (!isValidHexColor(color, true)) { + throw new Error(`Invalid language color: "${color}"`); + } + return ` - ${lang.name} ${hideProgress || hideValues ? "" : displayValue} + ${encodeHTML(lang.name)} ${hideProgress || hideValues ? "" : encodeHTML(displayValue)} `; @@ -439,6 +448,10 @@ const renderCompactLayout = ( let progressOffset = 0; const compactProgressBar = langs .map((lang) => { + if (!isValidHexColor(lang.color, true)) { + throw new Error(`Invalid language color: "${lang.color}"`); + } + const percentage = parseFloat( ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2), ); @@ -514,13 +527,17 @@ const renderDonutVerticalLayout = ( // Generate each donut vertical chart part for (const lang of langs) { + if (!isValidHexColor(lang.color, true)) { + throw new Error(`Invalid language color: "${lang.color}"`); + } + const percentage = (lang.size / totalLanguageSize) * 100; const circleLength = totalCircleLength * (percentage / 100); const delay = startDelayCoefficient * 100; circles.push(` - { // Generate each pie chart part for (const lang of langs) { + if (!isValidHexColor(lang.color, true)) { + throw new Error(`Invalid language color: "${lang.color}"`); + } + if (langs.length === 1) { paths.push(` { + if (!Number.isFinite(width)) { + throw new Error(`Invalid width: "${width}"`); + } + const centerX = width / 3; const centerY = width / 3; const radius = centerX - 60; const strokeWidth = 12; - const colors = langs.map((lang) => lang.color); + const colors = langs.map((lang) => { + if (!isValidHexColor(lang.color, true)) { + throw new Error(`Invalid language color: "${lang.color}"`); + } + return lang.color; + }); const langsPercents = langs.map((lang) => parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)), ); @@ -783,10 +813,14 @@ const renderDonutLayout = ( * @returns {string} No languages data SVG node string. */ const noLanguagesDataNode = ({ color, text, layout }) => { + if (!isValidHexColor(color, true)) { + throw new Error(`Invalid text color: "${color}"`); + } + return ` ${text} + }" y="11" class="stat bold" fill="${color}">${encodeHTML(text)} `; }; diff --git a/packages/core/src/cards/wakatime.js b/packages/core/src/cards/wakatime.js index a717e753587fc..72bdfff69fa15 100644 --- a/packages/core/src/cards/wakatime.js +++ b/packages/core/src/cards/wakatime.js @@ -1,6 +1,7 @@ import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; -import { getCardColors } from "../common/color.js"; +import { getCardColors, isValidHexColor } from "../common/color.js"; +import { encodeHTML } from "../common/html.js"; import languageColors from "../common/languageColors.json" with { type: "json" }; import { clampValue, lowercaseTrim } from "../common/ops.js"; import { createProgressNode, flexLayout } from "../common/render.js"; @@ -24,8 +25,12 @@ const TOTAL_TEXT_WIDTH = 275; * @returns {string} No coding activity SVG node string. */ const noCodingActivityNode = ({ color, text }) => { + if (!isValidHexColor(color, true)) { + throw new Error(`Invalid text color: "${color}"`); + } + return ` - ${text} + ${encodeHTML(text)} `; }; @@ -58,6 +63,13 @@ const formatLanguageValue = ({ display_format, lang }) => { * @returns {string} The compact layout language SVG node. */ const createCompactLangNode = ({ lang, x, y, display_format }) => { + if (!Number.isFinite(x)) { + throw new Error(`Invalid x: "${x}"`); + } + if (!Number.isFinite(y)) { + throw new Error(`Invalid y: "${y}"`); + } + // @ts-ignore const color = languageColors[lang.name] || "#858585"; const value = formatLanguageValue({ display_format, lang }); @@ -66,7 +78,7 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => { - ${lang.name} - ${value} + ${encodeHTML(lang.name)} - ${encodeHTML(value)} `; @@ -125,6 +137,13 @@ const createTextNode = ({ progressBarBackgroundColor, progressBarWidth, }) => { + if (!Number.isFinite(index)) { + throw new Error(`Invalid index: "${index}"`); + } + if (!Number.isFinite(progressBarWidth)) { + throw new Error(`Invalid progressBarWidth: "${progressBarWidth}"`); + } + const staggerDelay = (index + 3) * 150; const cardProgress = hideProgress ? null @@ -142,12 +161,12 @@ const createTextNode = ({ return ` - ${label}: + ${encodeHTML(label)}: ${value} + >${encodeHTML(value)} ${cardProgress} `; @@ -179,11 +198,15 @@ const recalculatePercentages = (languages) => { * @param {string} colors.textColor The text color. * @returns {string} Card CSS styles. */ -const getStyles = ({ +const getStyles = function ({ // eslint-disable-next-line no-unused-vars titleColor, textColor, -}) => { +}) { + if (!isValidHexColor(textColor, true)) { + throw new Error(`Invalid text color: "${textColor}"`); + } + return ` .stat { font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; diff --git a/packages/core/src/common/Card.ts b/packages/core/src/common/Card.ts index 7ae629f83c8cf..f19d1716bd755 100644 --- a/packages/core/src/common/Card.ts +++ b/packages/core/src/common/Card.ts @@ -1,3 +1,4 @@ +import { isValidGradient, isValidHexColor } from "./color.js"; import { encodeHTML } from "./html.js"; import { flexLayout } from "./render.js"; @@ -33,6 +34,8 @@ class Card { /** * Creates a new card instance. * + * The caller must ensure that the passed `titlePrefixIcon` is properly sanitized! + * * @param props Card arguments. * @param props.width Card width. * @param props.height Card height. @@ -40,7 +43,7 @@ class Card { * @param props.colors Card colors arguments. * @param props.customTitle Card custom title. * @param props.defaultTitle Card default title. - * @param props.titlePrefixIcon Card title prefix icon. + * @param props.titlePrefixIcon Sanitized card title prefix icon. */ constructor({ width = 100, @@ -65,13 +68,11 @@ class Card { this.hideBorder = false; this.hideTitle = false; - this.border_radius = border_radius; + this.border_radius = parseFloat(String(border_radius)); // returns theme based colors with proper overrides and defaults this.colors = colors; - this.title = encodeHTML( - customTitle === undefined ? defaultTitle : customTitle, - ); + this.title = customTitle === undefined ? defaultTitle : customTitle; this.css = ""; @@ -104,7 +105,9 @@ class Card { } /** - * @param value The CSS to add to the card. + * The caller must ensure that the passed `css` string is properly sanitized! + * + * @param value The sanitized CSS to add to the card. */ setCSS(value: string): void { this.css = value; @@ -121,10 +124,13 @@ class Card { * @param value Whether to hide the title or not. */ setHideTitle(value: boolean): void { - this.hideTitle = value; - if (value) { + if (value && !this.hideTitle) { this.height -= 30; } + if (!value && this.hideTitle) { + this.height += 30; + } + this.hideTitle = value; } /** @@ -144,7 +150,7 @@ class Card { y="0" class="header" data-testid="header" - >${this.title} + >${encodeHTML(this.title)} `; const prefixIcon = ` @@ -180,10 +186,12 @@ class Card { if (typeof this.colors.bgColor !== "object") { return ""; } + if (!isValidGradient(this.colors.bgColor)) { + throw new Error(`Invalid gradient: ${this.colors.bgColor.join(",")}`); + } const gradients = this.colors.bgColor.slice(1); - return typeof this.colors.bgColor === "object" - ? ` + return ` - ` - : ""; + `; } /** @@ -230,10 +237,38 @@ class Card { }; /** - * @param body The inner body of the card. + * The caller must ensure that the passed `body` string is properly sanitized! + * + * @param body The sanitized inner body of the card. * @returns The rendered card. */ render(body: string): string { + if (!Number.isFinite(this.border_radius)) { + throw new Error(`Invalid border radius: "${this.border_radius}"`); + } + if ( + this.colors.titleColor !== undefined && + !isValidHexColor(this.colors.titleColor, true) + ) { + throw new Error(`Invalid title color: "${this.colors.titleColor}"`); + } + if ( + this.colors.borderColor !== undefined && + !isValidHexColor(this.colors.borderColor, true) + ) { + throw new Error(`Invalid border color: "${this.colors.borderColor}"`); + } + if ( + this.colors.bgColor !== undefined && + !(typeof this.colors.bgColor === "object" + ? isValidGradient(this.colors.bgColor) + : isValidHexColor(this.colors.bgColor, true)) + ) { + throw new Error( + `Invalid background color: ${String(this.colors.bgColor)}`, + ); + } + return ` - ${this.a11yTitle} - ${this.a11yDesc} + ${encodeHTML(this.a11yTitle)} + ${encodeHTML(this.a11yDesc)}