Restrict notification action links to application paths
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
16
webui/src/security/actionUrl.ts
Normal file
16
webui/src/security/actionUrl.ts
Normal 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;
|
||||
}
|
||||
27
webui/tests/action-url.test.ts
Normal file
27
webui/tests/action-url.test.ts
Normal 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
16
webui/tsconfig.tests.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user