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 `