diff --git a/README.md b/README.md index ab94dab..cbcb3e8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ Shipped in the package but not part of the public API: | `charts/legend.js` | Legend element generation | | `charts/layout.js` | Shared layout calculations | +### Escaping +`renderSvg`, `renderError`, and legend generation HTML-escape their text +inputs (title, error message, language names). + ## Query parameters All parsing lives in `parseQueryParams`(utils/params.js), invalid params fall back to defaults. @@ -44,7 +48,7 @@ All parsing lives in `parseQueryParams`(utils/params.js), invalid params fall ba | bg, text, gap, c1 - c16 | from theme | Accepts a theme name, or a hex value (3-8 digits with or without #) | | gap_type | gap | `gap`, `grow`, or `adapt` | | stroke | false | Adds a black outline to slices and legend squares. | -| title | Top Languages | HTML-escaped | +| title | Top Languages | Custom SVG title | | hide_title | false | If `true` title is not rendered | | width / height | 400 / 300 | Integers, minimums: width 400, height 265 | diff --git a/package.json b/package.json index cccdc67..d58060c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gh-top-languages/lib", - "version": "1.2.2", + "version": "1.2.3", "description": "Library for gh-top-languages — chart generation, SVG output, and parameter parsing", "keywords": [ "svg", diff --git a/src/charts/legend.ts b/src/charts/legend.ts index 426e112..c8e62bc 100644 --- a/src/charts/legend.ts +++ b/src/charts/legend.ts @@ -1,6 +1,7 @@ import { LEGEND_SHIFT_THRESHOLD, LEGEND_STYLES } from "../constants/styles.js"; import type { Theme, Language, GapType } from "./types.js"; import { formatLegendEntry, resolveColour } from "./helpers.js"; +import { sanitize } from "../utils/sanitize.js"; export function createLegend( languages: Language[], @@ -49,7 +50,7 @@ export function createLegend( font-size="${LEGEND_STYLES.FONT_SIZE}" font-family="Arial" > - ${formatLegendEntry(lang, totalPct, gapType)} + ${sanitize(formatLegendEntry(lang, totalPct, gapType))} `; }).join(''); diff --git a/src/render/svg.ts b/src/render/svg.ts index 24847a1..249ea05 100644 --- a/src/render/svg.ts +++ b/src/render/svg.ts @@ -1,5 +1,6 @@ import { TITLE_STYLES } from "../constants/styles.js"; import type { ChartResult } from "../charts/types.js"; +import { sanitize } from "../utils/sanitize.js"; export function renderSvg( width: number, height: number, background: string, @@ -12,7 +13,7 @@ export function renderSvg( text-anchor="middle" fill="${textColour}" font-family="Arial" font-size="${TITLE_STYLES.FONT_SIZE}" > - ${title} + ${sanitize(title)} ` : ''; diff --git a/src/utils/params.ts b/src/utils/params.ts index 59f33e7..87236f3 100644 --- a/src/utils/params.ts +++ b/src/utils/params.ts @@ -2,7 +2,6 @@ import { VALID_TYPES } from "../constants/types.js"; import { DEFAULT_CONFIG } from "../constants/config.js"; import { THEMES } from "../constants/themes.js"; import type { ChartType, GapType, Theme } from "../charts/types.js"; -import { sanitize } from "./sanitize.js"; export interface ParsedParams { chartType: ChartType; @@ -31,7 +30,7 @@ const parseHex = (val: string | undefined, fallback: string): string => { return /^#[0-9a-f]{3,8}$/i.test(hex) ? hex : fallback; }; -const resolveColour = ( +const resolveThemeColour = ( query: QueryParams, theme: Theme, key: "bg" | "text" | "gap" @@ -62,14 +61,14 @@ export function parseQueryParams(query: QueryParams): ParsedParams { return { chartType, - chartTitle: query["hide_title"] === "true" ? '' : sanitize(query["title"] ?? DEFAULT_CONFIG.TITLE), + chartTitle: query["hide_title"] === "true" ? '' : query["title"] ?? DEFAULT_CONFIG.TITLE, width: Math.max(parseIntSafe(query["width"], DEFAULT_CONFIG.WIDTH), DEFAULT_CONFIG.MIN_WIDTH ), height: Math.max(parseIntSafe(query["height"], DEFAULT_CONFIG.HEIGHT), DEFAULT_CONFIG.MIN_HEIGHT), count: Math.min(Math.max(count, 1), DEFAULT_CONFIG.MAX_COUNT), selectedTheme: { - bg: resolveColour(query, baseTheme, "bg"), - text: resolveColour(query, baseTheme, "text"), - gap: resolveColour(query, baseTheme, "gap"), + bg: resolveThemeColour(query, baseTheme, "bg"), + text: resolveThemeColour(query, baseTheme, "text"), + gap: resolveThemeColour(query, baseTheme, "gap"), colours, }, gapType: (["gap", "grow", "adapt"] as const).includes(query["gap_type"] as GapType) ? query["gap_type"] as GapType : "gap", diff --git a/tests/charts/legend.test.ts b/tests/charts/legend.test.ts index 79c82ad..cbe1ce2 100644 --- a/tests/charts/legend.test.ts +++ b/tests/charts/legend.test.ts @@ -43,6 +43,13 @@ describe("createLegend", () => { expect(result).toContain(`stroke-width="0.5"`); }); + it("escapes markup in language names", () => { + const langs = [{ lang: ``, pct: 100 }]; + const result = createLegend(langs, theme, 300, false, 110, "gap"); + expect(result).toContain("<img src=x onerror=alert(1)> 100.0%"); + expect(result).not.toContain(" { const langs = [ { lang: "C#", pct: 50 }, diff --git a/tests/render/error.test.ts b/tests/render/error.test.ts index cfe3cd3..d7b5a9a 100644 --- a/tests/render/error.test.ts +++ b/tests/render/error.test.ts @@ -27,4 +27,10 @@ describe("renderError", () => { expect(result).toContain("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA..."); expect(result).not.toContain("A".repeat(50)); }); + + it("escapes markup in the message", () => { + const result = renderError(``, 400, 300); + expect(result).toContain("<script>"x"</script>"); + expect(result).not.toContain("`, "#000000"); + expect(result).toContain("<script>alert("x")</script>"); + expect(result).not.toContain("` }); - expect(params.chartTitle).toBe("<scripts>alert("x")</script>"); + it("returns title raw", () => { + const params = parseQueryParams({ title: `&"'` }); + expect(params.chartTitle).toBe(`&"'`); }); it("clamps count between 1 and MAX_COUNT", () => {