Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ You can also check the

## next release

- Fixes
- Sanitize HTML coming from cube metadata and from WMS / WMTS capabilities
documents instead of injecting it into the DOM.

## 6.5.2 – 2026-08-04

- Fixes
Expand Down
2 changes: 1 addition & 1 deletion app/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ DATABASE_URL=postgres://postgres:password@localhost:5432/visualization_tool
ENDPOINT=sparql+https://cached.lindas.admin.ch/query
SPARQL_GEO_ENDPOINT=https://geo.ld.admin.ch/query
GRAPHQL_ENDPOINT=/api/graphql
WHITELISTED_DATA_SOURCES=["Prod", "Prod-uncached", "Int", "Int-uncached", "Test", "Test-uncached"]
WHITELISTED_DATA_SOURCES='["Prod", "Prod-uncached", "Int", "Int-uncached", "Test", "Test-uncached"]'
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
MAPTILER_API_KEY=123
ADFS_PROFILE_URL=https://www.myaccount-r.eiam.admin.ch/
Expand Down
18 changes: 12 additions & 6 deletions app/browse/ui/dataset-result.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Trans } from "@lingui/macro";
import { Box, CardProps, Stack, Theme, Typography } from "@mui/material";
import { CardProps, Stack, Theme, Typography } from "@mui/material";
import { makeStyles } from "@mui/styles";
import clsx from "clsx";
import sortBy from "lodash/sortBy";
Expand All @@ -12,6 +12,8 @@ import { DateFormat } from "@/browse/ui/date-format";
import { Flex } from "@/components/flex";
import { MaybeTooltip } from "@/components/maybe-tooltip";
import { MotionCard, smoothPresenceProps } from "@/components/presence";
import { boldOnlySchema } from "@/components/sanitize-schema";
import { SanitizedHtml } from "@/components/sanitized-html";
import { Tag } from "@/components/tag";
import { PartialSearchCube } from "@/domain/data";
import { DataCubePublicationStatus } from "@/graphql/resolver-types";
Expand Down Expand Up @@ -97,11 +99,14 @@ export const DatasetResult = ({
onClick={disableTitleLink ? undefined : handleTitleClick}
>
{highlightedTitle ? (
<Box
<SanitizedHtml
className={classes.textWrapper}
component="span"
fontWeight={highlightedTitle === title ? 700 : 400}
dangerouslySetInnerHTML={{ __html: highlightedTitle }}
// Matches are already emphasized through <b>, so the rest of the
// title is rendered with a regular weight.
fontWeight={highlightedTitle.includes("<b>") ? 400 : 700}
html={highlightedTitle}
schema={boldOnlySchema}
/>
) : (
title
Expand All @@ -113,10 +118,11 @@ export const DatasetResult = ({
title={description ?? ""}
>
{highlightedDescription ? (
<Box
<SanitizedHtml
className={classes.textWrapper}
component="span"
dangerouslySetInnerHTML={{ __html: highlightedDescription }}
html={highlightedDescription}
schema={boldOnlySchema}
/>
) : (
description
Expand Down
78 changes: 78 additions & 0 deletions app/charts/map/map-custom-layers-legend.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import { CustomLayerDescription } from "@/charts/map/map-custom-layers-legend";
import { parseWMSContent } from "@/charts/map/wms-utils";

afterEach(cleanup);

// Reproduces an attacker-controlled GetCapabilities document whose <Abstract>
// carries an XSS payload next to legitimate formatting.
const CAPABILITIES = `<?xml version="1.0" encoding="UTF-8"?>
<WMS_Capabilities version="1.3.0">
<Service><Title>probe</Title></Service>
<Capability>
<Request>
<GetMap>
<DCPType><HTTP><Get>
<OnlineResource xlink:href="https://attacker.example/wms?"/>
</Get></HTTP></DCPType>
</GetMap>
</Request>
<Layer>
<Title>root</Title>
<Layer queryable="1">
<Name>x-probe-layer</Name>
<Title>Probe</Title>
<Abstract><![CDATA[<img src="x" onerror="alert(document.domain)"> Provided by <b>Swisstopo</b>, see <a href="https://example.com/info">details</a>.]]></Abstract>
<CRS>EPSG:3857</CRS>
</Layer>
</Layer>
</Capability>
</WMS_Capabilities>`;

describe("CustomLayerDescription", () => {
it("strips dangerous markup from WMS layer descriptions", () => {
const { container } = render(
<CustomLayerDescription description='<img src="x" onerror="alert(document.domain)">' />
);

expect(container.querySelector("img")).toBeNull();
expect(container.innerHTML).not.toContain("onerror");
});

it("keeps the formatting of WMS layer descriptions", () => {
const { container } = render(
<CustomLayerDescription description='<p>Data by <b>Swisstopo</b>, see <a href="https://example.com/info">details</a>.</p>' />
);

expect(container.querySelector("b")?.textContent).toBe("Swisstopo");

const link = screen.getByRole("link", { name: "details" });
expect(link.getAttribute("href")).toBe("https://example.com/info");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});

it("renders a hostile WMS abstract without executing it", () => {
const layers = parseWMSContent(
CAPABILITIES,
"https://attacker.example/wms"
);
const description = layers.find(
(d) => d.id === "x-probe-layer"
)?.description;

expect(description).toContain("onerror");

const { container } = render(
<CustomLayerDescription description={description ?? ""} />
);

expect(container.querySelector("img")).toBeNull();
expect(container.innerHTML).not.toContain("onerror");
expect(container.querySelector("b")?.textContent).toBe("Swisstopo");
expect(
screen.getByRole("link", { name: "details" }).getAttribute("href")
).toBe("https://example.com/info");
});
});
28 changes: 17 additions & 11 deletions app/charts/map/map-custom-layers-legend.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,6 +14,8 @@ import {
} from "@/charts/map/wmts-utils";
import { Error, InlineLoading } from "@/components/hint";
import { InfoIconTooltip } from "@/components/info-icon-tooltip";
import { inlineTextSchema } from "@/components/sanitize-schema";
import { SanitizedHtml } from "@/components/sanitized-html";
import { BaseLayer, MapConfig } from "@/config-types";
import { truthy } from "@/domain/types";
import { useLocale } from "@/locales/use-locale";
Expand Down Expand Up @@ -43,6 +45,19 @@ const constrainSize = ({
return { width, height };
};

export const CustomLayerDescription = ({
description,
}: {
description: string;
}) => (
<SanitizedHtml
html={description}
schema={inlineTextSchema}
// We do not let the tooltip HTML override the font size
sx={{ typography: "caption" }}
/>
);

export const MapCustomLayersLegend = ({
chartConfig,
value,
Expand All @@ -52,7 +67,6 @@ export const MapCustomLayersLegend = ({
}) => {
const customLayers = chartConfig.baseLayer.customLayers;
const { data: legendsData, error } = useLegendsData({ customLayers });
const theme = useTheme();
return error ? (
<Error>{error.message}</Error>
) : !legendsData ? (
Expand Down Expand Up @@ -99,15 +113,7 @@ export const MapCustomLayersLegend = ({
{layer.description ? (
<InfoIconTooltip
title={
<Box
sx={{
"& > *": {
// We do not let the tooltip HTML override the font size
fontSize: `${theme.typography.caption.fontSize} !important`,
},
}}
dangerouslySetInnerHTML={{ __html: layer.description }}
/>
<CustomLayerDescription description={layer.description} />

@hupf hupf Sep 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mburri That fixes the XSS issue, but it also breaks every formatting markup. Shouldn't we sanitize the description instead, like we did in other places? Or was there a decision, that no formatting is allowed here?

If formatting should still work, I'd introduce a sanitizing component, maybe leveraging the already present sanitizeSchema/rehypeSanitize? Otherwise DOMPurify would be the goto IMO.

Also, I assume there is another XSS in app/rdf/query-search.ts with the highlightedTitle and highlightedDescription.

}
sx={{ width: "fit-content" }}
/>
Expand Down
45 changes: 45 additions & 0 deletions app/components/dataset-metadata.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DatasetPublisher
publisher={
'<a href="https://example.com/?a=1&amp;b=2">FOEN &amp; BAFU</a>'
}
/>
);

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(
<DatasetPublisher
publisher={'<a href="javascript:alert(1)">Publisher</a>'}
/>
);

expect(screen.getByText("Publisher")).toBeTruthy();
expect(container.querySelector("a")).toBeNull();
});

it("renders plain text and strips unexpected markup", () => {
const { container } = render(
<DatasetPublisher
publisher={'Publisher <img src="x" onerror="alert(1)"> &amp; Office'}
/>
);

expect(container.textContent).toBe("Publisher & Office");
expect(container.querySelector("img")).toBeNull();
});
});
20 changes: 12 additions & 8 deletions app/components/dataset-metadata.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { Trans } from "@lingui/macro";
import {
Box,
Link,
Link as MUILink,
LinkProps,
Expand All @@ -15,6 +14,8 @@ import { ReactElement, ReactNode } from "react";

import { useQueryFilters } from "@/charts/shared/chart-helpers";
import { DataDownloadMenu } from "@/components/data-download";
import { inlineTextSchema } from "@/components/sanitize-schema";
import { SanitizedHtml } from "@/components/sanitized-html";
import { Tag } from "@/components/tag";
import { DataSource } from "@/configurator";
import { DataCubeMetadata } from "@/domain/data";
Expand Down Expand Up @@ -54,13 +55,7 @@ export const DatasetMetadata = ({
<Trans id="dataset.metadata.source">Source</Trans>
</DatasetMetadataTitle>
<DatasetMetadataBody>
<Box
component="span"
sx={{ "> a": { color: "grey.900" } }}
dangerouslySetInnerHTML={{
__html: cube.publisher,
}}
/>
<DatasetPublisher publisher={cube.publisher} />
</DatasetMetadataBody>
</div>
)}
Expand Down Expand Up @@ -173,6 +168,15 @@ const DatasetMetadataBody = ({
</Typography>
);

export const DatasetPublisher = ({ publisher }: { publisher: string }) => (
<SanitizedHtml
component="span"
html={publisher}
schema={inlineTextSchema}
sx={{ "> a": { color: "grey.900" } }}
/>
);

const DatasetMetadataLink = ({
href,
label,
Expand Down
32 changes: 17 additions & 15 deletions app/components/debug-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ 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 { boldOnlySchema } from "@/components/sanitize-schema";
import { SanitizedHtml } from "@/components/sanitized-html";
import { SearchCubeFilter, useSearchCubesQuery } from "@/graphql/query-hooks";
import { RequestQueryMeta } from "@/graphql/query-meta";
import { SearchCubeFilterType } from "@/graphql/resolver-types";

Expand Down Expand Up @@ -126,17 +125,20 @@ const Search = ({
({ cube, highlightedTitle, highlightedDescription }) => {
return (
<div key={cube.iri}>
<Typography
variant="h6"
dangerouslySetInnerHTML={{ __html: highlightedTitle! }}
/>
<Typography
variant="caption"
dangerouslySetInnerHTML={{
__html: highlightedDescription?.slice(0, 100) ?? "" + "...",
}}
/>
<br />
<Typography variant="h6">
<SanitizedHtml
component="span"
html={highlightedTitle ?? ""}
schema={boldOnlySchema}
/>
</Typography>
<Typography variant="caption" component="p" noWrap>
<SanitizedHtml
component="span"
html={highlightedDescription ?? ""}
schema={boldOnlySchema}
/>
</Typography>
<Typography variant="caption">{cube.iri}</Typography>
<Stack spacing={2} direction="row">
{cube.themes.map((t) => (
Expand Down
Loading
Loading