fix: secure widget navigation links

This commit is contained in:
Thomas Camlong committed 2026-08-02 10:01:36 +02:00
1 parent 1d73513989
commit d460ac8bbb
3 files changed
+30 -6

No files matched your search

+16 -3
View File
@@ -69,7 +69,7 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
<Card
key={feedEntry.id}
component={"a"}
href={getSafeExternalUrl(feedEntry.link, feedEntry.feedUrl) ?? getSafeExternalUrl(feedEntry.feedUrl)}
href={getSafeExternalUrl(feedEntry.link, feedEntry.feedUrl)}
radius={board.itemRadius}
target="_blank"
rel="noopener noreferrer"
@@ -141,8 +141,21 @@ export const getHostname = (url: string): string => {
export const getSafeExternalUrl = (value: unknown, baseUrl?: string): string | undefined => {
if (typeof value !== "string") return undefined;
try {
const url = new URL(value, baseUrl);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
let sanitizedBaseUrl: string | undefined;
if (baseUrl) {
const parsedBaseUrl = new URL(baseUrl);
parsedBaseUrl.username = "";
parsedBaseUrl.password = "";
parsedBaseUrl.search = "";
parsedBaseUrl.hash = "";
sanitizedBaseUrl = parsedBaseUrl.toString();
}
const url = new URL(value, sanitizedBaseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
url.username = "";
url.password = "";
return url.toString();
} catch {
return undefined;
}
@@ -14,9 +14,20 @@ describe("RSS feed display helpers", () => {
expect(getHostname("https://example.com/feed.xml")).toBe("example.com");
});
test("only exposes HTTP links from untrusted feed entries", () => {
test("only exposes credential-free HTTP links from untrusted feed entries", () => {
expect(getSafeExternalUrl("https://example.com/story")).toBe("https://example.com/story");
expect(getSafeExternalUrl("/story", "https://example.com/feed.xml")).toBe("https://example.com/story");
expect(getSafeExternalUrl("/story", "https://user:password@example.com/feed.xml")).toBe(
"https://example.com/story",
);
expect(getSafeExternalUrl("https://user:password@example.com/story")).toBe("https://example.com/story");
expect(getSafeExternalUrl(undefined, "https://example.com/feed?token=secret")).toBeUndefined();
expect(getSafeExternalUrl("#story", "https://example.com/feed?token=secret#old")).toBe(
"https://example.com/feed#story",
);
expect(getSafeExternalUrl("?story=1", "https://example.com/feed?token=secret")).toBe(
"https://example.com/feed?story=1",
);
expect(getSafeExternalUrl("javascript:alert(1)")).toBeUndefined();
expect(getSafeExternalUrl("not a URL")).toBeUndefined();
});
+2 -2
View File
@@ -60,8 +60,8 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }:
<Text fw="bold">{t("title", { station: station.label })}</Text>
{compactStaleWarning && displayMode === "compact" && (
<Group gap={2} wrap="nowrap">
<IconAlertTriangle aria-hidden size={12} color="var(--mantine-color-orange-6)" />
<Text component="output" size="xs" c="orange" style={{ whiteSpace: "nowrap" }}>
<IconAlertTriangle aria-hidden size={12} color="var(--mantine-color-orange-light-color)" />
<Text component="output" size="xs" c="var(--mantine-color-text)" style={{ whiteSpace: "nowrap" }}>
{compactStaleWarning}
</Text>
</Group>