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("", "