Explorar o código

Fixes #23502: Send a Permissions-Policy header denying unused browser features

Adds PermissionsPolicyMiddleware and a PERMISSIONS_POLICY parameter. By default every response denies camera, microphone, geolocation, payment, usb, hid, serial and bluetooth. An operator's dictionary replaces the default, an empty dictionary sends no header, and a header set by a view is left alone.
Jason Novinger hai 1 día
pai
achega
b354000b71

+ 38 - 0
docs/configuration/security.md

@@ -200,6 +200,44 @@ The view name or URL to which a user is redirected after logging out.
 
 
 ---
 ---
 
 
+## PERMISSIONS_POLICY
+
+Default:
+
+```python
+PERMISSIONS_POLICY = {
+    'camera': [],
+    'microphone': [],
+    'geolocation': [],
+    'payment': [],
+    'usb': [],
+    'hid': [],
+    'serial': [],
+    'bluetooth': [],
+}
+```
+
+Defines the `Permissions-Policy` header sent with every response, which tells the browser which features NetBox pages may use. Each key is a browser feature and each value is the list of origins allowed to use it. An empty list denies the feature everywhere. The default denies camera, microphone, location, payment, USB, HID, serial and Bluetooth access, none of which NetBox uses. Features not listed keep the browser default.
+
+Your dictionary replaces the default entirely, so copy the default and edit it. For example, a plugin that scans barcodes with the camera needs `camera` re-allowed for NetBox's own origin:
+
+```python
+PERMISSIONS_POLICY = {
+    'camera': ['self'],
+    'microphone': [],
+    'geolocation': [],
+    'payment': [],
+    'usb': [],
+    'hid': [],
+    'serial': [],
+    'bluetooth': [],
+}
+```
+
+Plugins that reach serial consoles or USB and HID devices from the browser need `serial`, `hid` or `usb` re-allowed the same way. Use `'self'` for NetBox's own origin and `'*'` for any origin. Any other entry is an origin such as `'https://scan.example.com'`, which may start with `*.` to match subdomains, though browser support for wildcards varies. USB barcode scanners that act as a keyboard are not affected by this header. Set `PERMISSIONS_POLICY = {}` to send no header. Values are checked at startup, and CSP's quoted keywords such as `CSP.SELF` are not accepted here.
+
+---
+
 ## SECURE_CSP
 ## SECURE_CSP
 
 
 Default: `{}`
 Default: `{}`

+ 26 - 0
netbox/netbox/middleware.py

@@ -28,6 +28,7 @@ __all__ = (
     'CoreMiddleware',
     'CoreMiddleware',
     'LoginRequiredMiddleware',
     'LoginRequiredMiddleware',
     'MaintenanceModeMiddleware',
     'MaintenanceModeMiddleware',
+    'PermissionsPolicyMiddleware',
     'PrometheusAfterMiddleware',
     'PrometheusAfterMiddleware',
     'PrometheusBeforeMiddleware',
     'PrometheusBeforeMiddleware',
     'RemoteUserMiddleware',
     'RemoteUserMiddleware',
@@ -52,6 +53,31 @@ class CommonMiddleware(DjangoCommonMiddleware):
         return super().should_redirect_with_slash(request)
         return super().should_redirect_with_slash(request)
 
 
 
 
+class PermissionsPolicyMiddleware:
+    """
+    Send a Permissions-Policy header built from the PERMISSIONS_POLICY setting. Each feature maps to a list of allowed
+    origins, where an empty list denies the feature. An empty setting sends no header, and a header already set by a
+    view is left alone.
+    """
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        response = self.get_response(request)
+        if settings.PERMISSIONS_POLICY and 'Permissions-Policy' not in response:
+            response['Permissions-Policy'] = self.build_header(settings.PERMISSIONS_POLICY)
+        return response
+
+    @staticmethod
+    def build_header(policy):
+        directives = []
+        for feature, origins in policy.items():
+            # "self" and "*" are structured-field tokens. Anything else is an origin, sent as a quoted string.
+            items = ' '.join(origin if origin in ('self', '*') else f'"{origin}"' for origin in origins)
+            directives.append(f'{feature}=({items})')
+        return ', '.join(directives)
+
+
 class CoreMiddleware:
 class CoreMiddleware:
 
 
     def __init__(self, get_response):
     def __init__(self, get_response):

+ 12 - 0
netbox/netbox/settings.py

@@ -26,6 +26,7 @@ from netbox.settings_utils import (
     parse_job_timeout,
     parse_job_timeout,
     resolve_install_paths,
     resolve_install_paths,
     secret_key_hint,
     secret_key_hint,
+    validate_permissions_policy,
     validate_webhook_default_timeout,
     validate_webhook_default_timeout,
 )
 )
 from utilities.release import load_release_data
 from utilities.release import load_release_data
@@ -167,6 +168,16 @@ LOGIN_FORM_HIDDEN = param('LOGIN_FORM_HIDDEN', False, types=bool)
 LOGOUT_REDIRECT_URL = param('LOGOUT_REDIRECT_URL', 'home', types=str)
 LOGOUT_REDIRECT_URL = param('LOGOUT_REDIRECT_URL', 'home', types=str)
 MEDIA_ROOT = param('MEDIA_ROOT', os.path.join(NETBOX_ROOT, 'media'), types=str).rstrip('/')
 MEDIA_ROOT = param('MEDIA_ROOT', os.path.join(NETBOX_ROOT, 'media'), types=str).rstrip('/')
 METRICS_ENABLED = param('METRICS_ENABLED', False, types=bool)
 METRICS_ENABLED = param('METRICS_ENABLED', False, types=bool)
+PERMISSIONS_POLICY = param('PERMISSIONS_POLICY', {
+    'camera': [],
+    'microphone': [],
+    'geolocation': [],
+    'payment': [],
+    'usb': [],
+    'hid': [],
+    'serial': [],
+    'bluetooth': [],
+}, types=dict, validators=[validate_permissions_policy])
 PLUGINS = param('PLUGINS', [], types=(list, tuple))
 PLUGINS = param('PLUGINS', [], types=(list, tuple))
 PLUGINS_CONFIG = param('PLUGINS_CONFIG', {}, types=dict)
 PLUGINS_CONFIG = param('PLUGINS_CONFIG', {}, types=dict)
 PLUGINS_CATALOG_CONFIG = param('PLUGINS_CATALOG_CONFIG', {}, types=dict)
 PLUGINS_CATALOG_CONFIG = param('PLUGINS_CATALOG_CONFIG', {}, types=dict)
@@ -532,6 +543,7 @@ MIDDLEWARE = [
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'django.middleware.csp.ContentSecurityPolicyMiddleware',
     'django.middleware.csp.ContentSecurityPolicyMiddleware',
+    'netbox.middleware.PermissionsPolicyMiddleware',
     'django_htmx.middleware.HtmxMiddleware',
     'django_htmx.middleware.HtmxMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.LoginRequiredMiddleware',
     'netbox.middleware.LoginRequiredMiddleware',

+ 63 - 0
netbox/netbox/settings_utils.py

@@ -3,10 +3,12 @@
 import importlib
 import importlib
 import importlib.util
 import importlib.util
 import os
 import os
+import re
 import sys
 import sys
 import threading
 import threading
 import warnings
 import warnings
 from typing import NamedTuple
 from typing import NamedTuple
+from urllib.parse import urlsplit
 
 
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from rq.exceptions import TimeoutFormatError
 from rq.exceptions import TimeoutFormatError
@@ -22,6 +24,7 @@ __all__ = (
     'parse_job_timeout',
     'parse_job_timeout',
     'resolve_install_paths',
     'resolve_install_paths',
     'secret_key_hint',
     'secret_key_hint',
+    'validate_permissions_policy',
     'validate_webhook_default_timeout',
     'validate_webhook_default_timeout',
 )
 )
 
 
@@ -121,6 +124,66 @@ def validate_webhook_default_timeout(timeout, job_timeout):
         )
         )
 
 
 
 
+_LABEL = r'[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?'
+ORIGIN_NETLOC_RE = re.compile(rf'(\*\.)?{_LABEL}(\.{_LABEL})*(:[0-9]+)?|\[[0-9A-Fa-f:.]+\](:[0-9]+)?')
+
+
+FEATURE_NAME_RE = re.compile(r'[a-z0-9-]+')
+
+
+def validate_permissions_policy(name, value):
+    """Validate PERMISSIONS_POLICY.
+
+    Feature names are checked for header-safe characters only, not against a list of known features.
+
+    Each value must be a list or tuple whose entries are 'self', '*', or a bare http(s) origin.
+    Anything else would be emitted into the header verbatim and silently discarded by browsers.
+    """
+    for feature, entries in value.items():
+        if not isinstance(feature, str) or FEATURE_NAME_RE.fullmatch(feature) is None:
+            raise ImproperlyConfigured(
+                f"{name} contains the invalid feature name {feature!r}: names are lowercase letters, digits and hyphens"
+            )
+        if not isinstance(entries, (list, tuple)):
+            raise ImproperlyConfigured(
+                f"{name}[{feature!r}] must be a list or tuple of allowed origins (found {entries!r})"
+            )
+        for entry in entries:
+            if entry in ('self', '*'):
+                continue
+            if entry in ("'self'", "'none'"):
+                raise ImproperlyConfigured(
+                    f"{name}[{feature!r}] contains the CSP keyword {entry!r}: use 'self' without the CSP quotes "
+                    f"(CSP.SELF is not accepted), or an empty list to deny the feature"
+                )
+            if not _is_origin(entry):
+                raise ImproperlyConfigured(
+                    f"{name}[{feature!r}] contains {entry!r}, which is not 'self', '*', or an origin such as "
+                    f"'https://scan.example.com'"
+                )
+
+
+def _is_origin(entry):
+    """
+    Return True if entry is exactly a lowercase http(s) scheme, an ASCII host and an optional valid port.
+
+    The host charset is strict because the entry is written into a structured-field header, where one
+    bad character makes the browser drop the whole policy.
+    """
+    if not isinstance(entry, str):
+        return False
+    try:
+        parts = urlsplit(entry)
+        parts.port  # Raises ValueError if the port is not a number from 0 to 65535
+    except ValueError:
+        return False
+    return (
+        parts.scheme in ('http', 'https')
+        and entry == f'{parts.scheme}://{parts.netloc}'
+        and ORIGIN_NETLOC_RE.fullmatch(parts.netloc) is not None
+    )
+
+
 def _import_module(name):
 def _import_module(name):
     """Import a configuration module by dotted path.
     """Import a configuration module by dotted path.
 
 

+ 49 - 0
netbox/netbox/tests/test_permissions_policy.py

@@ -0,0 +1,49 @@
+from django.http import HttpResponse
+from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
+from django.urls import reverse
+
+from netbox.middleware import PermissionsPolicyMiddleware
+
+HEADER = 'Permissions-Policy'
+
+DEFAULT_HEADER = 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), hid=(), serial=(), bluetooth=()'
+
+
+class PermissionsPolicyHeaderTestCase(TestCase):
+    """
+    Check the header on real responses, through the whole middleware stack.
+    """
+
+    def test_default_denies_the_default_deny_list(self):
+        response = self.client.get(reverse('login'))
+        self.assertEqual(response[HEADER], DEFAULT_HEADER)
+
+    @override_settings(PERMISSIONS_POLICY={'camera': ['self'], 'geolocation': []})
+    def test_custom_policy_replaces_default(self):
+        response = self.client.get(reverse('login'))
+        self.assertEqual(response[HEADER], 'camera=(self), geolocation=()')
+
+    @override_settings(PERMISSIONS_POLICY={'camera': ['self', 'https://scan.example.com']})
+    def test_origins_are_quoted_and_self_is_a_token(self):
+        response = self.client.get(reverse('login'))
+        self.assertEqual(response[HEADER], 'camera=(self "https://scan.example.com")')
+
+    @override_settings(PERMISSIONS_POLICY={})
+    def test_empty_policy_sends_no_header(self):
+        response = self.client.get(reverse('login'))
+        self.assertNotIn(HEADER, response)
+
+
+class PermissionsPolicyMiddlewareTestCase(SimpleTestCase):
+    """
+    Check the middleware in isolation, with a view that sets the header itself.
+    """
+
+    def test_header_set_by_view_is_untouched(self):
+        def view(request):
+            response = HttpResponse()
+            response[HEADER] = 'fullscreen=(self)'
+            return response
+
+        response = PermissionsPolicyMiddleware(view)(RequestFactory().get('/'))
+        self.assertEqual(response[HEADER], 'fullscreen=(self)')

+ 65 - 0
netbox/netbox/tests/test_settings_utils.py

@@ -397,6 +397,71 @@ class ValidateWebhookDefaultTimeoutTest(SimpleTestCase):
                     settings_utils.validate_webhook_default_timeout(timeout, 300)
                     settings_utils.validate_webhook_default_timeout(timeout, 300)
 
 
 
 
+class ValidatePermissionsPolicyTest(SimpleTestCase):
+    """validate_permissions_policy() is the startup check applied to PERMISSIONS_POLICY."""
+
+    def test_valid_policies(self):
+        valid = (
+            {},
+            {'camera': []},
+            {'camera': ['self']},
+            {'camera': ['*']},
+            {'camera': ('self',)},
+            {'camera': ['self', 'https://scan.example.com'], 'usb': []},
+            {'camera': ['http://localhost:8000']},
+            {'camera': ['https://[::1]:8443']},
+            {'camera': ['https://*.example.com']},
+            {'camera': ['https://*.example.com:8443']},
+            {'camera': ['https://localhost']},
+            {'camera': ['https://xn--bcher-kva.example']},
+        )
+        for value in valid:
+            with self.subTest(value=value):
+                settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', value)
+
+    def test_csp_quoted_keywords_raise(self):
+        for entry in ("'self'", "'none'"):
+            with self.subTest(entry=entry):
+                with self.assertRaisesMessage(ImproperlyConfigured, 'without the CSP quotes'):
+                    settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', {'camera': [entry]})
+
+    def test_error_names_feature_and_entry(self):
+        with self.assertRaisesMessage(ImproperlyConfigured, "camera"):
+            settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', {'camera': ['scan.example.com']})
+        with self.assertRaisesMessage(ImproperlyConfigured, "'scan.example.com'"):
+            settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', {'camera': ['scan.example.com']})
+
+    def test_malformed_feature_names_raise(self):
+        for feature in ('camera ()', 'cam=era', 'Camera', '', 5):
+            with self.subTest(feature=feature):
+                with self.assertRaisesMessage(ImproperlyConfigured, repr(feature)):
+                    settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', {feature: []})
+
+    def test_well_formed_feature_names_are_not_checked_against_a_list(self):
+        for feature in ('camera', 'publickey-credentials-get', 'future-feature'):
+            with self.subTest(feature=feature):
+                settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', {feature: []})
+
+    def test_bare_string_value_raises(self):
+        with self.assertRaisesMessage(ImproperlyConfigured, 'must be a list or tuple'):
+            settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', {'camera': 'self'})
+
+    def test_invalid_origins_raise(self):
+        invalid = (
+            'scan.example.com', 'ftp://scan.example.com', 'https://', 'https://example.com/path', '', 5,
+            'https://u@x', 'https://x:abc', 'https://x:99999', 'https://x:', 'https://x?', 'https://x#',
+            'https://x y', 'https:// x', 'HTTPS://x',
+            'https://x"y', 'https://x,y', 'https://x;y', 'https://x%20y', 'https://x\x00', 'https://x\x7f',
+            'https://bücher.example',
+            'https://*', 'https://ex*ample.com', 'https://a*b.example.com', 'https://*.*.example.com',
+            'https://-', 'https://..', 'https://.example.com', 'https://example.com.', 'https://*.example.com:',
+        )
+        for entry in invalid:
+            with self.subTest(entry=entry):
+                with self.assertRaises(ImproperlyConfigured):
+                    settings_utils.validate_permissions_policy('PERMISSIONS_POLICY', {'camera': [entry]})
+
+
 class LoadLdapConfigTest(SimpleTestCase):
 class LoadLdapConfigTest(SimpleTestCase):
     def test_loads_sibling_ldap_config(self):
     def test_loads_sibling_ldap_config(self):
         with tempfile.TemporaryDirectory() as conf_dir:
         with tempfile.TemporaryDirectory() as conf_dir: