diff --git a/app/charts/map/map-custom-layers-legend.spec.tsx b/app/charts/map/map-custom-layers-legend.spec.tsx new file mode 100644 index 000000000..6a542a6fd --- /dev/null +++ b/app/charts/map/map-custom-layers-legend.spec.tsx @@ -0,0 +1,19 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { CustomLayerDescription } from "@/charts/map/map-custom-layers-legend"; + +afterEach(cleanup); + +describe("CustomLayerDescription", () => { + it("renders WMS layer descriptions as text", () => { + const description = ''; + + const { container } = render( + + ); + + expect(screen.getByText(description)).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + }); +}); diff --git a/app/charts/map/map-custom-layers-legend.tsx b/app/charts/map/map-custom-layers-legend.tsx index 39cdce5ec..5c9564d64 100644 --- a/app/charts/map/map-custom-layers-legend.tsx +++ b/app/charts/map/map-custom-layers-legend.tsx @@ -1,4 +1,4 @@ -import { Box, Typography, useTheme } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import uniq from "lodash/uniq"; import NextImage from "next/image"; @@ -43,6 +43,12 @@ const constrainSize = ({ return { width, height }; }; +export const CustomLayerDescription = ({ + description, +}: { + description: string; +}) => {description}; + export const MapCustomLayersLegend = ({ chartConfig, value, @@ -52,7 +58,6 @@ export const MapCustomLayersLegend = ({ }) => { const customLayers = chartConfig.baseLayer.customLayers; const { data: legendsData, error } = useLegendsData({ customLayers }); - const theme = useTheme(); return error ? ( {error.message} ) : !legendsData ? ( @@ -99,15 +104,7 @@ export const MapCustomLayersLegend = ({ {layer.description ? ( *": { - // We do not let the tooltip HTML override the font size - fontSize: `${theme.typography.caption.fontSize} !important`, - }, - }} - dangerouslySetInnerHTML={{ __html: layer.description }} - /> + } sx={{ width: "fit-content" }} /> diff --git a/app/components/dataset-metadata.spec.tsx b/app/components/dataset-metadata.spec.tsx new file mode 100644 index 000000000..7d1c3f3c6 --- /dev/null +++ b/app/components/dataset-metadata.spec.tsx @@ -0,0 +1,45 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { DatasetPublisher } from "@/components/dataset-metadata"; + +afterEach(cleanup); + +describe("DatasetPublisher", () => { + it("renders publisher anchor markup as a safe link", () => { + render( + FOEN & BAFU' + } + /> + ); + + const link = screen.getByRole("link", { name: "FOEN & BAFU" }); + expect(link.getAttribute("href")).toBe("https://example.com/?a=1&b=2"); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("does not render unsafe publisher URLs as links", () => { + const { container } = render( + Publisher'} + /> + ); + + expect(screen.getByText("Publisher")).toBeTruthy(); + expect(container.querySelector("a")).toBeNull(); + }); + + it("renders plain text and strips unexpected markup", () => { + const { container } = render( + & Office'} + /> + ); + + expect(container.textContent).toBe("Publisher & Office"); + expect(container.querySelector("img")).toBeNull(); + }); +}); diff --git a/app/components/dataset-metadata.tsx b/app/components/dataset-metadata.tsx index d42c2ab50..ddab536a9 100644 --- a/app/components/dataset-metadata.tsx +++ b/app/components/dataset-metadata.tsx @@ -1,7 +1,6 @@ import { sanitizeUrl } from "@braintree/sanitize-url"; import { Trans } from "@lingui/macro"; import { - Box, Link, Link as MUILink, LinkProps, @@ -54,13 +53,7 @@ export const DatasetMetadata = ({ Source - a": { color: "grey.900" } }} - dangerouslySetInnerHTML={{ - __html: cube.publisher, - }} - /> + )} @@ -173,6 +166,67 @@ const DatasetMetadataBody = ({ ); +export const DatasetPublisher = ({ publisher }: { publisher: string }) => { + const { text, href } = parsePublisher(publisher); + + return href ? ( + + {text} + + ) : ( + <>{text} + ); +}; + +const decodeHtmlEntities = (text: string) => { + const namedEntities: Record = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + quot: '"', + }; + + return text.replace(/&(#(?:x[\da-f]+|\d+)|[a-z]+);/gi, (entity, code) => { + if (code[0] !== "#") { + return namedEntities[code.toLowerCase()] ?? entity; + } + + const value = + code[1].toLowerCase() === "x" + ? parseInt(code.slice(2), 16) + : parseInt(code.slice(1), 10); + return Number.isSafeInteger(value) && value >= 0 && value <= 0x10ffff + ? String.fromCodePoint(value) + : entity; + }); +}; + +const publisherText = (publisher: string) => { + return decodeHtmlEntities(publisher.replace(/<[^>]+>/g, "")); +}; + +const parsePublisher = (publisher: string): { text: string; href?: string } => { + const match = publisher.match( + /]+href=["']([^"']+)["'][^>]*>(.*?)<\/a>/is + ); + + if (match) { + const href = sanitizeUrl(decodeHtmlEntities(match[1])); + const text = publisherText(match[2]); + + return href !== "about:blank" ? { text, href } : { text }; + } + + return { text: publisherText(publisher) }; +}; + const DatasetMetadataLink = ({ href, label, diff --git a/app/components/debug-search.tsx b/app/components/debug-search.tsx index 1ab7d5433..3c9c27b16 100644 --- a/app/components/debug-search.tsx +++ b/app/components/debug-search.tsx @@ -10,10 +10,7 @@ import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; import { KeyboardEventHandler, useEffect, useRef, useState } from "react"; -import { - SearchCubeFilter, - useSearchCubesQuery, -} from "@/graphql/query-hooks"; +import { SearchCubeFilter, useSearchCubesQuery } from "@/graphql/query-hooks"; import { RequestQueryMeta } from "@/graphql/query-meta"; import { SearchCubeFilterType } from "@/graphql/resolver-types"; @@ -133,7 +130,7 @@ const Search = ({
diff --git a/app/middleware.ts b/app/middleware.ts index 6ae9db30c..3327585e3 100644 --- a/app/middleware.ts +++ b/app/middleware.ts @@ -32,7 +32,7 @@ function buildCSP(frameAncestors: string): string { return [ `default-src 'self' 'unsafe-inline'${unsafeEval}${sentryCSP}${vercelDefault}`, - `script-src 'self' 'unsafe-inline'${unsafeEval}${sentryCSP}${vercelScript} https://api.mapbox.com https://api.maptiler.com`, + `script-src 'self'${unsafeEval}${sentryCSP}${vercelScript} https://api.mapbox.com https://api.maptiler.com`, `script-src-elem 'self' 'unsafe-inline' https://*.admin.ch https://visualize.admin.ch https://*.visualize.admin.ch${vercelScriptElem} https://api.mapbox.com`, `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, `font-src 'self'`, diff --git a/app/rdf/query-search-score-utils.spec.ts b/app/rdf/query-search-score-utils.spec.ts index 90d319412..e1ca4eccf 100644 --- a/app/rdf/query-search-score-utils.spec.ts +++ b/app/rdf/query-search-score-utils.spec.ts @@ -22,4 +22,29 @@ describe("highlighting search words in query", () => { expect(result).toEqual(t[2]); } }); + + it("should escape HTML contained in the text", () => { + expect(highlight(' bad', "bad")).toEqual( + "<img src=x onerror="alert(1)"> bad" + ); + }); + + it("should escape HTML contained in the matched part", () => { + expect(highlight("", "