Skip to content
Merged
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
12 changes: 9 additions & 3 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@ jobs:
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run:
yarn e2e -- --grep-invert @noci --shard=${{ matrix.shardIndex }}/${{
matrix.shardTotal }}
# The secret value itself is masked in the logs, so derive a yes/no
# using the same condition as e2e/common.ts.
run: |
if [ -z "$E2E_HAR" ] || [ "$E2E_HAR" = "false" ]; then
echo "Replaying traffic from HAR file: no (requests hit the live data source)"
else
echo "Replaying traffic from HAR file: yes"
fi
yarn e2e -- --grep-invert @noci --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
E2E_BASE_URL: ${{ github.event.deployment_status.target_url }}
E2E_HAR: ${{ secrets.E2E_HAR }}
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ You can also check the

- Fixes
- Improve CSP header
- 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

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