fix: secure widget navigation links
This commit is contained in:
3 files changed
+30
-6
No files matched your search
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user