From d6562a21aae7dc65a0408b12d3df8a26b711c1f3 Mon Sep 17 00:00:00 2001 From: Eeshu-Yadav Date: Fri, 10 Jul 2026 16:26:55 +0530 Subject: [PATCH] [change] Honor notification data["url"] for the web target link The web notification widget links to the serialized target_url. Return data["url"] when it is set so a per-notification link (for example a generic_message sent with url=...) points the notification at a specific page, consistent with the email link which already uses data["url"]. The model target_url is left unchanged so message templates that render {target_link} keep pointing at the target object. The override is restricted to relative paths and http(s) URLs; the widget assigns target_url to window.location, so a javascript: scheme would otherwise execute on click. --- openwisp_notifications/api/serializers.py | 15 ++++++++ .../tests/test_notifications.py | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/openwisp_notifications/api/serializers.py b/openwisp_notifications/api/serializers.py index 42d91348..6b34c85f 100644 --- a/openwisp_notifications/api/serializers.py +++ b/openwisp_notifications/api/serializers.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urlparse from django.db import models from rest_framework import serializers @@ -47,6 +48,20 @@ def get_field_names(self, declared_fields, info): model_fields = super().get_field_names(declared_fields, info) return model_fields + self.Meta.extra_fields + def to_representation(self, instance): + data = super().to_representation(instance) + url = (instance.data or {}).get("url") + if url and self._is_safe_target_url(url): + data["target_url"] = url + return data + + @staticmethod + def _is_safe_target_url(url): + """Allows only relative paths or http(s) URLs as the redirect target.""" + if url.startswith("//"): + return False + return urlparse(url).scheme in ("", "http", "https") + @property def data(self): try: diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index aa12fbf4..f6138a90 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -25,6 +25,7 @@ from openwisp_notifications import settings as app_settings from openwisp_notifications import tasks, utils +from openwisp_notifications.api.serializers import NotificationSerializer from openwisp_notifications.exceptions import NotificationRenderException from openwisp_notifications.handlers import ( notify_handler, @@ -771,6 +772,42 @@ def test_notification_type_related_object_url(self): notification.target_url, "https://target.example.com/index#heading" ) + def test_target_url_data_override(self): + target = self._create_user(username="target", email="target@example.com") + self.notification_options.update({"target": target}) + with self.subTest("data url is used as the notification link"): + self._create_notification() + n = Notification.objects.first() + self.assertEqual( + NotificationSerializer(n).data["target_url"], + "https://localhost:8000/admin", + ) + self.assertIn(str(target.pk), n.target_url) + with self.subTest("a relative data url is honored"): + Notification.objects.all().delete() + self.notification_options.update({"url": "/admin/?status=pending"}) + self._create_notification() + n = Notification.objects.first() + self.assertEqual( + NotificationSerializer(n).data["target_url"], "/admin/?status=pending" + ) + with self.subTest("an unsafe scheme falls back to the target object URL"): + for unsafe in ("javascript:alert(1)", "//evil.example.com"): + Notification.objects.all().delete() + self.notification_options.update({"url": unsafe}) + self._create_notification() + n = Notification.objects.first() + self.assertEqual( + NotificationSerializer(n).data["target_url"], n.target_url + ) + with self.subTest("falls back to the target object URL without a data url"): + Notification.objects.all().delete() + self.notification_options.pop("url") + self._create_notification() + n = Notification.objects.first() + self.assertEqual(NotificationSerializer(n).data["target_url"], n.target_url) + self.assertIn(str(target.pk), n.target_url) + @capture_any_output() @mock_notification_types @patch("openwisp_notifications.tasks.delete_notification.delay")