Compare commits

...

2 Commits

Author SHA1 Message Date
Martin Hauser
d501f56a5f fix(extras): Validate EventRule action_data is a dict or null
Add validation in EventRule.clean() to ensure action_data is a JSON
object or null. Add runtime guard in event processing to handle legacy
rows with invalid data by logging a warning and using an empty dict.

Fixes #21989
2026-04-24 14:14:48 +02:00
Martin Hauser
b1a810164a fix(dcim): Add color field to FrontPort form
Include the color field in FrontPortForm and commented-out
FrontPortBulkCreateForm field lists to allow editing front port colors
via the UI.

Fixes #21985
2026-04-23 12:09:23 -04:00
6 changed files with 82 additions and 4 deletions

View File

@@ -94,7 +94,7 @@ class InterfaceBulkCreateForm(
# class FrontPortBulkCreateForm( # class FrontPortBulkCreateForm(
# form_from_model(FrontPort, ['label', 'type', 'description', 'tags']), # form_from_model(FrontPort, ['label', 'type', 'color', 'description', 'tags']),
# DeviceBulkAddComponentForm # DeviceBulkAddComponentForm
# ): # ):
# pass # pass

View File

@@ -1166,7 +1166,7 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
FieldSet('device_type', name=_('Device Type')), FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')), FieldSet('module_type', name=_('Module Type')),
), ),
'name', 'label', 'type', 'positions', 'rear_ports', 'description', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description',
), ),
) )

View File

@@ -181,9 +181,26 @@ def process_event_rules(event_rules, object_type, event):
if not event_rule.eval_conditions(event['data']): if not event_rule.eval_conditions(event['data']):
continue continue
# Guard against action_data that is valid JSON but not a dict
# (e.g. a bare string or number). Existing rows with bad data are
# tolerated at runtime; validation on EventRule.clean() prevents
# new ones.
if event_rule.action_data is None:
action_data = {}
elif isinstance(event_rule.action_data, dict):
action_data = event_rule.action_data
else:
logger.warning(
_('Ignoring invalid action_data on event rule "{rule}" (got {data_type})').format(
rule=event_rule,
data_type=type(event_rule.action_data).__name__,
)
)
action_data = {}
# Merge rule-specific action_data with the event payload. # Merge rule-specific action_data with the event payload.
# Copy to avoid mutating the rule's stored action_data dict. # Copy to avoid mutating the rule's stored action_data dict.
event_data = {**(event_rule.action_data or {}), **event['data']} event_data = {**action_data, **event['data']}
# Webhooks # Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK: if event_rule.action_type == EventRuleActionChoices.WEBHOOK:

View File

@@ -143,6 +143,10 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, OwnerMixin, TagsMixin,
except ValueError as e: except ValueError as e:
raise ValidationError({'conditions': e}) raise ValidationError({'conditions': e})
# action_data must be a JSON object (or null)
if self.action_data is not None and not isinstance(self.action_data, dict):
raise ValidationError({'action_data': _('Action data must be a JSON object or null.')})
def eval_conditions(self, data): def eval_conditions(self, data):
""" """
Test whether the given data meets the conditions of the event rule (if any). Return True Test whether the given data meets the conditions of the event rule (if any). Return True

View File

@@ -563,3 +563,30 @@ class EventRuleTest(APITestCase):
job = self.queue.get_jobs()[0] job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.queue.empty() self.queue.empty()
def test_non_dict_action_data_does_not_crash_flush(self):
"""
Pre-existing non-dict action_data must not cause flush_events() to
raise.
"""
site_type = ObjectType.objects.get_for_model(Site)
webhook = Webhook.objects.get(name='Webhook 1')
webhook_type = ObjectType.objects.get_for_model(Webhook)
bad_rule = EventRule.objects.create(
name='Bad action_data rule',
event_types=[OBJECT_CREATED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhook.pk,
action_data={},
)
bad_rule.object_types.set([site_type])
# Simulate a legacy row that predates model validation.
EventRule.objects.filter(pk=bad_rule.pk).update(action_data='not a dict')
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, {'name': 'Site X', 'slug': 'site-x'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)

View File

@@ -11,9 +11,18 @@ from django.forms import ValidationError
from django.test import TestCase, tag from django.test import TestCase, tag
from PIL import Image from PIL import Image
from core.events import OBJECT_CREATED
from core.models import AutoSyncRecord, DataSource, ObjectType from core.models import AutoSyncRecord, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem from extras.models import (
ConfigContext,
ConfigContextProfile,
ConfigTemplate,
EventRule,
ImageAttachment,
Tag,
TaggedItem,
)
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -889,3 +898,24 @@ class ConfigTemplateTest(TestCase):
object_id=config_template.pk object_id=config_template.pk
) )
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching") self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
class EventRuleTest(TestCase):
def test_action_data_clean_accepts_dict(self):
"""
clean() should accept a JSON object (or null) as action_data.
"""
for value in ({'key': 'value'}, None):
rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
rule.clean()
def test_action_data_clean_rejects_non_dict(self):
"""
clean() should reject action_data that is valid JSON but not an object (#21989).
"""
for value in ('test', 42, [1, 2, 3], True):
rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
with self.assertRaises(ValidationError) as cm:
rule.clean()
self.assertIn('action_data', cm.exception.message_dict)