From 738368a6a142eec8ae43899755fa2773a3177ff1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Oct 2019 14:47:40 -0400 Subject: [PATCH 1/7] Closes #3471: Disallow raw HTML in Markdown-rendered fields --- CHANGELOG.md | 1 + netbox/utilities/templatetags/helpers.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca874bea..993a89c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ v2.6.6 (FUTURE) * [#1941](https://github.com/netbox-community/netbox/issues/1941) - Add InfiniBand interface types * [#3259](https://github.com/netbox-community/netbox/issues/3259) - Add `rack` and `site` filters for cables +* [#3471](https://github.com/netbox-community/netbox/issues/3471) - Disallow raw HTML in Markdown-rendered fields * [#3563](https://github.com/netbox-community/netbox/issues/3563) - Enable editing of individual DeviceType components * [#3580](https://github.com/netbox-community/netbox/issues/3580) - Render text and URL fields as textareas in the custom link form diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index e6616d888..7b1e059a6 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -3,6 +3,7 @@ import json import re from django import template +from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown @@ -58,7 +59,12 @@ def gfm(value): """ Render text as GitHub-Flavored Markdown """ + # Strip HTML tags + value = strip_tags(value) + + # Render Markdown with GFM extension html = markdown(value, extensions=['mdx_gfm']) + return mark_safe(html) From d402b6c3e5949d83f0202b31e1fa1b44c45e6b2a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Oct 2019 15:06:00 -0400 Subject: [PATCH 2/7] Closes #3545: Add MultiObjectVar for custom scripts --- CHANGELOG.md | 1 + netbox/extras/scripts.py | 20 +++++++++++++++++++- netbox/extras/tests/test_scripts.py | 23 +++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 993a89c3b..d57fe1bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ v2.6.6 (FUTURE) * [#1941](https://github.com/netbox-community/netbox/issues/1941) - Add InfiniBand interface types * [#3259](https://github.com/netbox-community/netbox/issues/3259) - Add `rack` and `site` filters for cables * [#3471](https://github.com/netbox-community/netbox/issues/3471) - Disallow raw HTML in Markdown-rendered fields +* [#3545](https://github.com/netbox-community/netbox/issues/3545) - Add `MultiObjectVar` for custom scripts * [#3563](https://github.com/netbox-community/netbox/issues/3563) - Enable editing of individual DeviceType components * [#3580](https://github.com/netbox-community/netbox/issues/3580) - Render text and URL fields as textareas in the custom link form diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 2ce365609..83cda69ab 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -11,7 +11,7 @@ from django import forms from django.conf import settings from django.core.validators import RegexValidator from django.db import transaction -from mptt.forms import TreeNodeChoiceField +from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField from mptt.models import MPTTModel from ipam.formfields import IPFormField @@ -27,6 +27,7 @@ __all__ = [ 'FileVar', 'IntegerVar', 'IPNetworkVar', + 'MultiObjectVar', 'ObjectVar', 'Script', 'StringVar', @@ -149,6 +150,23 @@ class ObjectVar(ScriptVariable): self.form_field = TreeNodeChoiceField +class MultiObjectVar(ScriptVariable): + """ + Like ObjectVar, but can represent one or more objects. + """ + form_field = forms.ModelMultipleChoiceField + + def __init__(self, queryset, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Queryset for field choices + self.field_attrs['queryset'] = queryset + + # Update form field for MPTT (nested) objects + if issubclass(queryset.model, MPTTModel): + self.form_field = TreeNodeMultipleChoiceField + + class FileVar(ScriptVariable): """ An uploaded file. diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 5372ae9d8..f9fc98ff2 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -120,6 +120,29 @@ class ScriptVariablesTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data['var1'].pk, data['var1']) + def test_multiobjectvar(self): + + class TestScript(Script): + + var1 = MultiObjectVar( + queryset=DeviceRole.objects.all() + ) + + # Populate some objects + for i in range(1, 6): + DeviceRole( + name='Device Role {}'.format(i), + slug='device-role-{}'.format(i) + ).save() + + # Validate valid data + data = {'var1': [role.pk for role in DeviceRole.objects.all()[:3]]} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'][0].pk, data['var1'][0]) + self.assertEqual(form.cleaned_data['var1'][1].pk, data['var1'][1]) + self.assertEqual(form.cleaned_data['var1'][2].pk, data['var1'][2]) + def test_filevar(self): class TestScript(Script): From 99f7cfcbd34f3cf3e43f1724e292d0f21442665e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Oct 2019 15:16:50 -0400 Subject: [PATCH 3/7] Closes #3581: Introduce commit_default custom script attribute to not commit changes by default --- CHANGELOG.md | 1 + docs/additional-features/custom-scripts.md | 8 ++++++++ netbox/extras/forms.py | 6 +++++- netbox/extras/scripts.py | 2 +- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d57fe1bba..fb5a321b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ v2.6.6 (FUTURE) * [#3545](https://github.com/netbox-community/netbox/issues/3545) - Add `MultiObjectVar` for custom scripts * [#3563](https://github.com/netbox-community/netbox/issues/3563) - Enable editing of individual DeviceType components * [#3580](https://github.com/netbox-community/netbox/issues/3580) - Render text and URL fields as textareas in the custom link form +* [#3581](https://github.com/netbox-community/netbox/issues/3581) - Introduce `commit_default` custom script attribute to not commit changes by default --- diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index fb8b70d67..c00f54d4c 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -63,6 +63,14 @@ A list of field names indicating the order in which the form fields should appea field_order = ['var1', 'var2', 'var3'] ``` +### `commit_default` + +The checkbox to commit database changes when executing a script is checked by default. Set `commit_default` to False under the script's Meta class to leave this option unchecked by default. + +``` +commit_default = False +``` + ## Reading Data from Files The Script class provides two convenience methods for reading data from files: diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 071345913..efb92b2ce 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -427,7 +427,7 @@ class ScriptForm(BootstrapMixin, forms.Form): help_text="Commit changes to the database (uncheck for a dry-run)" ) - def __init__(self, vars, *args, **kwargs): + def __init__(self, vars, *args, commit_default=True, **kwargs): super().__init__(*args, **kwargs) @@ -435,6 +435,10 @@ class ScriptForm(BootstrapMixin, forms.Form): for name, var in vars.items(): self.fields[name] = var.as_field() + # Toggle default commit behavior based on Meta option + if not commit_default: + self.fields['_commit'].initial = False + # Move _commit to the end of the form self.fields.move_to_end('_commit', True) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 83cda69ab..f83cffdea 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -243,7 +243,7 @@ class BaseScript: Return a Django form suitable for populating the context data required to run this Script. """ vars = self._get_vars() - form = ScriptForm(vars, data, files) + form = ScriptForm(vars, data, files, commit_default=getattr(self.Meta, 'commit_default', True)) return form From 6cdeb0ed958042a3bb53dffba2f7c8797f3b31a0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Oct 2019 15:25:31 -0400 Subject: [PATCH 4/7] Fixes #3463: Correct CSV headers for exported power feeds --- CHANGELOG.md | 1 + netbox/dcim/models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5a321b5..ba9a171c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.6.6 (FUTURE) ## Bug Fixes +* [#3463](https://github.com/netbox-community/netbox/issues/3463) - Correct CSV headers for exported power feeds * [#3571](https://github.com/netbox-community/netbox/issues/3571) - Prevent erroneous redirects when editing tags * [#3573](https://github.com/netbox-community/netbox/issues/3573) - Ensure consistent display of changelog retention period * [#3574](https://github.com/netbox-community/netbox/issues/3574) - Change `device` to `parent` in interface editing VLAN filtering logic diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 8f5393af2..984bb2798 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -3108,6 +3108,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): def to_csv(self): return ( + self.power_panel.site.name, self.power_panel.name, self.rack.name if self.rack else None, self.name, From c770b13903378025281ba483795dd944437efe47 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Oct 2019 15:44:32 -0400 Subject: [PATCH 5/7] Fixes #3474: Fix device status page loading when NAPALM call fails --- CHANGELOG.md | 1 + netbox/templates/dcim/device_status.html | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9a171c4..3f2fda94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ v2.6.6 (FUTURE) ## Bug Fixes * [#3463](https://github.com/netbox-community/netbox/issues/3463) - Correct CSV headers for exported power feeds +* [#3474](https://github.com/netbox-community/netbox/issues/3474) - Fix device status page loading when NAPALM call fails * [#3571](https://github.com/netbox-community/netbox/issues/3571) - Prevent erroneous redirects when editing tags * [#3573](https://github.com/netbox-community/netbox/issues/3573) - Ensure consistent display of changelog retention period * [#3574](https://github.com/netbox-community/netbox/issues/3574) - Change `device` to `parent` in interface editing VLAN filtering logic diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index 5d7f0770a..ffb61286e 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device_status.html @@ -94,8 +94,11 @@ $(document).ready(function() { var row="" + name + "" + obj['%usage'] + "%"; $("#cpu").after(row) }); - $('#memory').after("Used" + json['get_environment']['memory']['used_ram'] + ""); - $('#memory').after("Available" + json['get_environment']['memory']['available_ram'] + ""); + if (json['get_environment']['memory']) { + var memory = $('#memory'); + memory.after("Used" + json['get_environment']['memory']['used_ram'] + ""); + memory.after("Available" + json['get_environment']['memory']['available_ram'] + ""); + } $.each(json['get_environment']['temperature'], function(name, obj) { var style = "success"; if (obj['is_alert']) { From 896d58fc3f83c358408e95f42b7228a529e108ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Oct 2019 16:22:06 -0400 Subject: [PATCH 6/7] Fixes #3458: Prevent primary IP address for a device/VM from being reassigned --- CHANGELOG.md | 1 + netbox/ipam/models.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2fda94f..88270a0df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.6.6 (FUTURE) ## Bug Fixes +* [#3458](https://github.com/netbox-community/netbox/issues/3458) - Prevent primary IP address for a device/VM from being reassigned * [#3463](https://github.com/netbox-community/netbox/issues/3463) - Correct CSV headers for exported power feeds * [#3474](https://github.com/netbox-community/netbox/issues/3474) - Fix device status page loading when NAPALM call fails * [#3571](https://github.com/netbox-community/netbox/issues/3571) - Prevent erroneous redirects when editing tags diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 3304c27dd..8f9b64b59 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -9,10 +9,11 @@ from django.db.models.expressions import RawSQL from django.urls import reverse from taggit.managers import TaggableManager -from dcim.models import Interface +from dcim.models import Device, Interface from extras.models import CustomFieldModel, ObjectChange, TaggedItem from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object +from virtualization.models import VirtualMachine from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet @@ -636,6 +637,34 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): ) }) + if self.pk: + + # Check for primary IP assignment that doesn't match the assigned device/VM + device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() + if device: + if self.interface is None: + raise ValidationError({ + 'interface': "IP address is primary for device {} but not assigned".format(device) + }) + elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device: + raise ValidationError({ + 'interface': "IP address is primary for device {} but assigned to {} ({})".format( + device, self.interface.device, self.interface + ) + }) + vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() + if vm: + if self.interface is None: + raise ValidationError({ + 'interface': "IP address is primary for virtual machine {} but not assigned".format(vm) + }) + elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm: + raise ValidationError({ + 'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format( + vm, self.interface.virtual_machine, self.interface + ) + }) + def save(self, *args, **kwargs): # Record address family From 0a921d37f8a09c83a9a8b850e14c05615002030c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 9 Oct 2019 16:45:33 -0400 Subject: [PATCH 7/7] Fixes #3582: Enforce view permissions on global search results --- CHANGELOG.md | 1 + netbox/netbox/views.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88270a0df..5f1ab5053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ v2.6.6 (FUTURE) * [#3573](https://github.com/netbox-community/netbox/issues/3573) - Ensure consistent display of changelog retention period * [#3574](https://github.com/netbox-community/netbox/issues/3574) - Change `device` to `parent` in interface editing VLAN filtering logic * [#3575](https://github.com/netbox-community/netbox/issues/3575) - Restore label for comments field when bulk editing circuits +* [#3582](https://github.com/netbox-community/netbox/issues/3582) - Enforce view permissions on global search results ## Enhancements diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index b26d45db5..05036a37a 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -40,43 +40,54 @@ SEARCH_MAX_RESULTS = 15 SEARCH_TYPES = OrderedDict(( # Circuits ('provider', { + 'permission': 'circuits.view_provider', 'queryset': Provider.objects.all(), 'filter': ProviderFilter, 'table': ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { - 'queryset': Circuit.objects.prefetch_related('type', 'provider', 'tenant').prefetch_related('terminations__site'), + 'permission': 'circuits.view_circuit', + 'queryset': Circuit.objects.prefetch_related( + 'type', 'provider', 'tenant' + ).prefetch_related( + 'terminations__site' + ), 'filter': CircuitFilter, 'table': CircuitTable, 'url': 'circuits:circuit_list', }), # DCIM ('site', { + 'permission': 'dcim.view_site', 'queryset': Site.objects.prefetch_related('region', 'tenant'), 'filter': SiteFilter, 'table': SiteTable, 'url': 'dcim:site_list', }), ('rack', { + 'permission': 'dcim.view_rack', 'queryset': Rack.objects.prefetch_related('site', 'group', 'tenant', 'role'), 'filter': RackFilter, 'table': RackTable, 'url': 'dcim:rack_list', }), ('rackgroup', { + 'permission': 'dcim.view_rackgroup', 'queryset': RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')), 'filter': RackGroupFilter, 'table': RackGroupTable, 'url': 'dcim:rackgroup_list', }), ('devicetype', { + 'permission': 'dcim.view_devicetype', 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')), 'filter': DeviceTypeFilter, 'table': DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { + 'permission': 'dcim.view_device', 'queryset': Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), @@ -85,18 +96,21 @@ SEARCH_TYPES = OrderedDict(( 'url': 'dcim:device_list', }), ('virtualchassis', { + 'permission': 'dcim.view_virtualchassis', 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')), 'filter': VirtualChassisFilter, 'table': VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), ('cable', { + 'permission': 'dcim.view_cable', 'queryset': Cable.objects.all(), 'filter': CableFilter, 'table': CableTable, 'url': 'dcim:cable_list', }), ('powerfeed', { + 'permission': 'dcim.view_powerfeed', 'queryset': PowerFeed.objects.all(), 'filter': PowerFeedFilter, 'table': PowerFeedTable, @@ -104,30 +118,35 @@ SEARCH_TYPES = OrderedDict(( }), # IPAM ('vrf', { + 'permission': 'ipam.view_vrf', 'queryset': VRF.objects.prefetch_related('tenant'), 'filter': VRFFilter, 'table': VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { + 'permission': 'ipam.view_aggregate', 'queryset': Aggregate.objects.prefetch_related('rir'), 'filter': AggregateFilter, 'table': AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { + 'permission': 'ipam.view_prefix', 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'filter': PrefixFilter, 'table': PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { + 'permission': 'ipam.view_ipaddress', 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), 'filter': IPAddressFilter, 'table': IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { + 'permission': 'ipam.view_vlan', 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), 'filter': VLANFilter, 'table': VLANTable, @@ -135,6 +154,7 @@ SEARCH_TYPES = OrderedDict(( }), # Secrets ('secret', { + 'permission': 'secrets.view_secret', 'queryset': Secret.objects.prefetch_related('role', 'device'), 'filter': SecretFilter, 'table': SecretTable, @@ -142,6 +162,7 @@ SEARCH_TYPES = OrderedDict(( }), # Tenancy ('tenant', { + 'permission': 'tenancy.view_tenant', 'queryset': Tenant.objects.prefetch_related('group'), 'filter': TenantFilter, 'table': TenantTable, @@ -149,12 +170,14 @@ SEARCH_TYPES = OrderedDict(( }), # Virtualization ('cluster', { + 'permission': 'virtualization.view_cluster', 'queryset': Cluster.objects.prefetch_related('type', 'group'), 'filter': ClusterFilter, 'table': ClusterTable, 'url': 'virtualization:cluster_list', }), ('virtualmachine', { + 'permission': 'virtualization.view_virtualmachine', 'queryset': VirtualMachine.objects.prefetch_related( 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', ), @@ -244,11 +267,16 @@ class SearchView(View): if form.is_valid(): # Searching for a single type of object + obj_types = [] if form.cleaned_data['obj_type']: - obj_types = [form.cleaned_data['obj_type']] + obj_type = form.cleaned_data['obj_type'] + if request.user.has_perm(SEARCH_TYPES[obj_type]['permission']): + obj_types.append(form.cleaned_data['obj_type']) # Searching all object types else: - obj_types = SEARCH_TYPES.keys() + for obj_type in SEARCH_TYPES.keys(): + if request.user.has_perm(SEARCH_TYPES[obj_type]['permission']): + obj_types.append(obj_type) for obj_type in obj_types: