Restrict notification action links to application paths

This commit is contained in:
2026-07-21 03:16:51 +02:00
parent 92fb409973
commit ae144a13e0
8 changed files with 160 additions and 7 deletions

View File

@@ -13,6 +13,9 @@
},
"./styles/notifications.css": "./src/styles/notifications.css"
},
"scripts": {
"test:action-url": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.tests.json && node .component-test-build/tests/action-url.test.js"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react";
import { Button, DismissibleAlert, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications";
import { safeNotificationActionUrl } from "../../security/actionUrl";
type StatusFilter = "all" | "pending" | "queued" | "sent" | "failed" | "skipped" | "cancelled";
@@ -171,6 +172,7 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
}
function NotificationDetails({ notification }: { notification: NotificationMessage }) {
const actionUrl = safeNotificationActionUrl(notification.action_url);
return (
<div className="notifications-detail">
<section className="notifications-message">
@@ -181,8 +183,8 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
</div>
<h1>{notification.subject || notification.event_kind}</h1>
{notification.body_text ? <p>{notification.body_text}</p> : <p className="muted">No message body was provided.</p>}
{notification.action_url ? (
<a className="notifications-action-link" href={notification.action_url}>
{actionUrl ? (
<a className="notifications-action-link" href={actionUrl}>
<ExternalLink size={16} /> Open related item
</a>
) : null}

View File

@@ -0,0 +1,16 @@
export function safeNotificationActionUrl(value: string | null | undefined): string | null {
const candidate = value?.trim();
if (
!candidate
|| !candidate.startsWith("/")
|| candidate.startsWith("//")
|| candidate.includes("\\")
|| [...candidate].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint < 32 || codePoint === 127;
})
) {
return null;
}
return candidate;
}

View File

@@ -0,0 +1,27 @@
import { safeNotificationActionUrl } from "../src/security/actionUrl";
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
}
assertEqual(
safeNotificationActionUrl(" /calendar?event=event-1#details "),
"/calendar?event=event-1#details",
"application-relative links remain available"
);
for (const unsafe of [
"javascript:alert(1)",
"data:text/html,unsafe",
"https://attacker.example.test/collect",
"//attacker.example.test/collect",
"/\\attacker.example.test/collect",
"/calendar\nmalformed",
"/calendar\u007fmalformed"
]) {
assertEqual(safeNotificationActionUrl(unsafe), null, `unsafe link is suppressed: ${unsafe}`);
}
assertEqual(safeNotificationActionUrl(null), null, "missing links remain absent");

16
webui/tsconfig.tests.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": ".component-test-build",
"rootDir": "."
},
"include": [
"src/security/actionUrl.ts",
"tests/action-url.test.ts"
]
}