From f3d2876c1642f255879c0cf98bbfe01ef3725b6a Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:52:24 +0200 Subject: [PATCH 1/9] fix workflow permissions and JsDoc param name --- .github/workflows/repeat-recent-requests.yml | 2 ++ packages/core/tests/utils.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repeat-recent-requests.yml b/.github/workflows/repeat-recent-requests.yml index 40987e5b2e66d..a7fd6e7ca1441 100644 --- a/.github/workflows/repeat-recent-requests.yml +++ b/.github/workflows/repeat-recent-requests.yml @@ -13,6 +13,8 @@ on: - cron: "45 * * * *" workflow_dispatch: +permissions: {} + jobs: triggerRepeatRecent: if: | diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index 1a4c5205ceb00..c064de4950086 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -17,7 +17,7 @@ import type * as loggerModule from "../src/common/log.js"; * * @param expected The expected numeric value to compare against. * - * @param + * @param precision * The number of decimal places of tolerance. Higher values mean stricter * comparison. Internally converted to epsilon = 10^-precision. * From fc1e83422be676b9f16a9352ab853668eb3146d3 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:15:42 +0200 Subject: [PATCH 2/9] adjust repeat-recent --- .github/workflows/repeat-recent-requests.yml | 2 +- apps/backend/src/repeatRequests.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repeat-recent-requests.yml b/.github/workflows/repeat-recent-requests.yml index a7fd6e7ca1441..536199b728b70 100644 --- a/.github/workflows/repeat-recent-requests.yml +++ b/.github/workflows/repeat-recent-requests.yml @@ -10,7 +10,7 @@ on: # │ │ │ │ │ # │ │ │ │ │ # * * * * * - - cron: "45 * * * *" + - cron: "15,45 * * * *" workflow_dispatch: permissions: {} diff --git a/apps/backend/src/repeatRequests.js b/apps/backend/src/repeatRequests.js index 29cddf318775e..582a135809136 100644 --- a/apps/backend/src/repeatRequests.js +++ b/apps/backend/src/repeatRequests.js @@ -66,6 +66,6 @@ export async function repeatRecentRequests() { if (urls.length === 0) { console.log("No recent requests found."); } else { - await makeRequests(urls, 5); + await makeRequests(urls, 3); } } From 40f214d3daf602c9e639c8ea145de3d1f10742c3 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:35:22 +0200 Subject: [PATCH 3/9] validate input colors --- packages/core/src/api/gist.js | 18 ++++++++ packages/core/src/api/index.js | 19 +++++++++ packages/core/src/api/pin.js | 18 ++++++++ packages/core/src/api/top-langs.js | 18 ++++++++ packages/core/src/api/wakatime.js | 18 ++++++++ packages/core/src/common/Card.ts | 6 +-- packages/core/src/common/color.ts | 42 ++++++++++++++++++- packages/core/src/common/render.js | 2 +- packages/core/src/fetchers/repo.js | 2 +- packages/core/tests/renderGistCard.test.js | 12 ++++++ packages/core/tests/renderRepoCard.test.js | 16 +++++++ packages/core/tests/renderStatsCard.test.js | 15 +++++++ .../core/tests/renderTopLanguagesCard.test.js | 15 +++++++ .../core/tests/renderWakatimeCard.test.js | 15 +++++++ 14 files changed, 208 insertions(+), 8 deletions(-) diff --git a/packages/core/src/api/gist.js b/packages/core/src/api/gist.js index 0cb9610262ca8..5cea744870cc0 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.ts"; 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..e0213b7f95bfc 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.ts"; 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..a6296813cb821 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.ts"; 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..d9e6e5b4173ae 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.ts"; 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..036bd9e7e3c4e 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.ts"; 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/common/Card.ts b/packages/core/src/common/Card.ts index 7ae629f83c8cf..8d3e67d4af220 100644 --- a/packages/core/src/common/Card.ts +++ b/packages/core/src/common/Card.ts @@ -182,8 +182,7 @@ class Card { } const gradients = this.colors.bgColor.slice(1); - return typeof this.colors.bgColor === "object" - ? ` + return ` - ` - : ""; + `; } /** diff --git a/packages/core/src/common/color.ts b/packages/core/src/common/color.ts index 2e9de30545e01..32a032b4c91d6 100644 --- a/packages/core/src/common/color.ts +++ b/packages/core/src/common/color.ts @@ -21,10 +21,48 @@ const isValidHexColor = (hexColor: string): boolean => { const isValidGradient = (colors: Array): boolean => { return ( colors.length > 2 && - colors.slice(1).every((color) => isValidHexColor(color)) + colors + .slice(1) + .every( + (color) => + isValidHexColor(color) && + !isNaN(Number(colors[0])) && + colors[0]?.trim() !== "", + ) ); }; +/** + * Checks if a string is a valid input for a color or gradient. + * + * @param color String to check, may be null or undefined. + * @returns True if the given string is a valid input. + */ +const isValidColorInput = (color: string | null | undefined): boolean => { + if (color === null || color === undefined) { + return true; + } + const colors = color.split(","); + return isValidGradient(colors) || isValidHexColor(color); +}; + +/** + * Iterates over a collection of colors inputs and verifies that each is a valid color or gradient. + * + * @param colors Object whose values are checked as valid color inputs. + * @return The first key where the associated input value is not valid. null if all inputs are valid. + */ +const findInvalidColor = ( + colors: Record, +): string | null => { + for (const [key, value] of Object.entries(colors)) { + if (!isValidColorInput(value)) { + return key; + } + } + return null; +}; + /** * Retrieves a gradient if color has more than one valid hex codes else a single color. * @@ -146,4 +184,4 @@ const getCardColors = ({ return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor }; }; -export { fallbackColor, getCardColors }; +export { fallbackColor, getCardColors, findInvalidColor }; diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.js index 24dd8c624e2aa..6d05238a9ff68 100644 --- a/packages/core/src/common/render.js +++ b/packages/core/src/common/render.js @@ -238,7 +238,7 @@ const renderError = ({ } ${encodeHTML(message)} - ${secondaryMessage} + ${encodeHTML(secondaryMessage)} `; diff --git a/packages/core/src/fetchers/repo.js b/packages/core/src/fetchers/repo.js index 2458f244100ea..793a3fd84d347 100644 --- a/packages/core/src/fetchers/repo.js +++ b/packages/core/src/fetchers/repo.js @@ -53,7 +53,7 @@ const fetcher = (variables, token) => { ); }; -const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; +const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; /** * @typedef {import("./types").RepositoryData} RepositoryData Repository data. diff --git a/packages/core/tests/renderGistCard.test.js b/packages/core/tests/renderGistCard.test.js index 123f01c52a140..2fb45bc69692e 100644 --- a/packages/core/tests/renderGistCard.test.js +++ b/packages/core/tests/renderGistCard.test.js @@ -2,6 +2,7 @@ import { queryByTestId } from "@testing-library/dom"; import { cssToObject } from "@uppercod/css-to-object"; import { describe, expect, it } from "vitest"; +import gistApi from "../src/api/gist.js"; import { renderGistCard } from "../src/cards/gist.js"; import { themes } from "../src/themes/index.js"; @@ -257,3 +258,14 @@ describe("test renderGistCard", () => { ); }); }); + +describe("test gist API", () => { + it("should return permanent error for invalid color input", async () => { + const result = await gistApi({ id: "abc123", title_color: "not-a-color" }); + + expect(result.status).toBe("error - permanent"); + expect(result.content).toContain( + `Invalid color input for parameter "title_color"`, + ); + }); +}); diff --git a/packages/core/tests/renderRepoCard.test.js b/packages/core/tests/renderRepoCard.test.js index 04fd8557fb6ea..4edb5fa45a09f 100644 --- a/packages/core/tests/renderRepoCard.test.js +++ b/packages/core/tests/renderRepoCard.test.js @@ -2,6 +2,7 @@ import { queryByTestId } from "@testing-library/dom"; import { cssToObject } from "@uppercod/css-to-object"; import { describe, expect, it } from "vitest"; +import pinApi from "../src/api/pin.js"; import { renderRepoCard } from "../src/cards/repo.js"; import { themes } from "../src/themes/index.js"; @@ -400,3 +401,18 @@ describe("Test renderRepoCard", () => { expect(document.querySelector("svg")).toHaveAttribute("height", "120"); }); }); + +describe("test pin API", () => { + it("should return a permanent error for an invalid color parameter", async () => { + const result = await pinApi({ + username: "user", + repo: "repo", + title_color: "not-a-color", + }); + + expect(result.status).toBe("error - permanent"); + expect(result.content).toContain( + `Invalid color input for parameter "title_color"`, + ); + }); +}); diff --git a/packages/core/tests/renderStatsCard.test.js b/packages/core/tests/renderStatsCard.test.js index 62b75a0d30bf2..a7002516cf536 100644 --- a/packages/core/tests/renderStatsCard.test.js +++ b/packages/core/tests/renderStatsCard.test.js @@ -6,6 +6,7 @@ import { import { cssToObject } from "@uppercod/css-to-object"; import { describe, expect, it } from "vitest"; +import statsApi from "../src/api/index.js"; import { renderStatsCard } from "../src/cards/stats.js"; import { CustomError } from "../src/common/error.js"; import { themes } from "../src/themes/index.js"; @@ -464,3 +465,17 @@ describe("Test renderStatsCard", () => { ); }); }); + +describe("test stats API", () => { + it("should return a permanent error for an invalid color parameter", async () => { + const result = await statsApi({ + username: "user", + title_color: "not-a-color", + }); + + expect(result.status).toBe("error - permanent"); + expect(result.content).toContain( + `Invalid color input for parameter "title_color"`, + ); + }); +}); diff --git a/packages/core/tests/renderTopLanguagesCard.test.js b/packages/core/tests/renderTopLanguagesCard.test.js index d80e286034c76..5de82135edd97 100644 --- a/packages/core/tests/renderTopLanguagesCard.test.js +++ b/packages/core/tests/renderTopLanguagesCard.test.js @@ -2,6 +2,7 @@ import { queryAllByTestId, queryByTestId } from "@testing-library/dom"; import { cssToObject } from "@uppercod/css-to-object"; import { describe, expect, it } from "vitest"; +import topLangsApi from "../src/api/top-langs.js"; import { MIN_CARD_WIDTH, calculateCompactLayoutHeight, @@ -995,3 +996,17 @@ describe("Test renderTopLanguages", () => { ); }); }); + +describe("test top-langs API", () => { + it("should return a permanent error for an invalid color parameter", async () => { + const result = await topLangsApi({ + username: "user", + title_color: "not-a-color", + }); + + expect(result.status).toBe("error - permanent"); + expect(result.content).toContain( + `Invalid color input for parameter "title_color"`, + ); + }); +}); diff --git a/packages/core/tests/renderWakatimeCard.test.js b/packages/core/tests/renderWakatimeCard.test.js index ba8577c79b6a8..b801b12790b6e 100644 --- a/packages/core/tests/renderWakatimeCard.test.js +++ b/packages/core/tests/renderWakatimeCard.test.js @@ -1,6 +1,7 @@ import { queryByTestId } from "@testing-library/dom"; import { describe, expect, it } from "vitest"; +import wakatimeApi from "../src/api/wakatime.js"; import { renderWakatimeCard } from "../src/cards/wakatime.js"; import { wakaTimeData } from "./fetchWakatime.test.js"; @@ -91,3 +92,17 @@ describe("Test Render WakaTime Card", () => { expect(card).toMatchSnapshot(); }); }); + +describe("test wakatime API", () => { + it("should return a permanent error for an invalid color parameter", async () => { + const result = await wakatimeApi({ + username: "user", + title_color: "not-a-color", + }); + + expect(result.status).toBe("error - permanent"); + expect(result.content).toContain( + `Invalid color input for parameter "title_color"`, + ); + }); +}); From 1a0f734e3b82234d9f3c52b02133ddd4bbdb8da5 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:04:15 +0200 Subject: [PATCH 4/9] prevent cross site scripting, part 1 --- packages/core/src/cards/repo.js | 39 +++++--- packages/core/src/cards/stats.js | 19 +++- packages/core/src/common/Card.ts | 53 ++++++++--- packages/core/src/common/color.ts | 16 +++- packages/core/src/common/html.ts | 2 +- packages/core/src/common/render.js | 102 ++++++++++++++++----- packages/core/tests/html.test.ts | 2 +- packages/core/tests/renderGistCard.test.js | 4 +- 8 files changed, 180 insertions(+), 57 deletions(-) diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index a352f357c1ea5..af900c19e9d85 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. diff --git a/packages/core/src/cards/stats.js b/packages/core/src/cards/stats.js index cbaf2b61f3e43..91c6ef7086d2a 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.ts"; 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)}: ${this.title} + >${encodeHTML(this.title)} `; const prefixIcon = ` @@ -180,6 +186,9 @@ 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 ` @@ -228,10 +237,32 @@ 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 (!isValidHexColor(this.colors.titleColor ?? "", true)) { + throw new Error( + `Invalid title color: ${this.colors.titleColor ?? ""}`, + ); + } + if (!isValidHexColor(this.colors.borderColor ?? "", true)) { + throw new Error( + `Invalid border color: ${this.colors.borderColor ?? ""}`, + ); + } + if ( + !(typeof this.colors.bgColor === "object" + ? isValidGradient(this.colors.bgColor) + : isValidHexColor(this.colors.bgColor ?? "")) + ) { + throw new Error( + `Invalid background color: ${String(this.colors.bgColor)}`, + ); + } + return ` - ${this.a11yTitle} - ${this.a11yDesc} + ${encodeHTML(this.a11yTitle)} + ${encodeHTML(this.a11yDesc)}