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
19 changes: 19 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,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 = '<img src="x" onerror="alert(document.domain)">';

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

expect(screen.getByText(description)).toBeTruthy();
expect(container.querySelector("img")).toBeNull();
});
});
19 changes: 8 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 Down Expand Up @@ -43,6 +43,12 @@ const constrainSize = ({
return { width, height };
};

export const CustomLayerDescription = ({
description,
}: {
description: string;
}) => <Box>{description}</Box>;

export const MapCustomLayersLegend = ({
chartConfig,
value,
Expand All @@ -52,7 +58,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 +104,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} />
}
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();
});
});
70 changes: 62 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 Down Expand Up @@ -54,13 +53,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 +166,67 @@ const DatasetMetadataBody = ({
</Typography>
);

export const DatasetPublisher = ({ publisher }: { publisher: string }) => {
const { text, href } = parsePublisher(publisher);

return href ? (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{ color: "grey.900" }}
>
{text}
</Link>
) : (
<>{text}</>
);
};

const decodeHtmlEntities = (text: string) => {
const namedEntities: Record<string, string> = {
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(
/<a[^>]+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,
Expand Down
7 changes: 2 additions & 5 deletions app/components/debug-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -133,7 +130,7 @@ const Search = ({
<Typography
variant="caption"
dangerouslySetInnerHTML={{
__html: highlightedDescription?.slice(0, 100) ?? "" + "...",
__html: `${highlightedDescription?.slice(0, 100) ?? ""}...`,
}}
/>
<br />
Expand Down
2 changes: 1 addition & 1 deletion app/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'`,
Expand Down
25 changes: 25 additions & 0 deletions app/rdf/query-search-score-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<img src=x onerror="alert(1)"> bad', "bad")).toEqual(
"&lt;img src=x onerror=&quot;alert(1)&quot;&gt; <b>bad</b>"
);
});

it("should escape HTML contained in the matched part", () => {
expect(highlight("<script>alert(1)</script>", "<script>")).toEqual(
"<b>&lt;script&gt;</b>alert(1)&lt;/script&gt;"
);
});

it("should treat regex special characters in the query literally", () => {
expect(highlight("Report about C++ usage", "C++")).toEqual(
"Report about <b>C++</b> usage"
);
expect(highlight("Pollution is bad", "(")).toEqual("Pollution is bad");
});

it("should not highlight empty matches for queries with extra spaces", () => {
expect(highlight("Pollution is bad", "is bad")).toEqual(
"Pollution <b>is</b> <b>bad</b>"
);
});
});
41 changes: 39 additions & 2 deletions app/rdf/query-search-score-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,44 @@ export const computeScores = (
return infoPerCube;
};

const escapeHtml = (text: string) => {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};

const escapeRegExp = (text: string) => {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};

/**
* Wraps the parts of `text` matching `query` in `<b>` tags.
*
* The text comes from remote cube metadata and the query from user input, so both
* are escaped: the text as HTML, since the result is rendered as markup, and the
* query as a regular expression, so that special characters (e.g. "C++") are
* matched literally instead of throwing or matching unexpectedly.
*/
export const highlight = (text: string, query: string) => {
const re = new RegExp(query.toLowerCase().split(" ").join("|"), "gi");
return text.replace(re, (m) => `<b>${m}</b>`);
const tokens = query
.split(" ")
.filter((d) => d !== "")
.map((d) => escapeRegExp(d));

if (!tokens.length) {
return escapeHtml(text);
}

const re = new RegExp(`(${tokens.join("|")})`, "gi");

// Odd indices contain the captured matches.
return text
.split(re)
.map((part, i) =>
i % 2 === 1 ? `<b>${escapeHtml(part)}</b>` : escapeHtml(part)
)
.join("");
};
Loading
Loading