Просмотр исходного кода

Fixes #23038: Don't append cache-busting parameter to signed static file URLs

Storage backends such as S3 may return presigned URLs whose signature covers
the entire query string. Appending a version parameter to such a URL after it
has been signed invalidates the signature, causing the storage backend to
reject the request with a 403.

Return signed URLs unmodified. These embed an expiration and are regenerated
on each request, so they require no cache-busting parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jeremy Stretch 1 день назад
Родитель
Сommit
f91e0e9fb3

+ 25 - 1
netbox/utilities/templatetags/builtins/tags.py

@@ -21,6 +21,14 @@ __all__ = (
 
 register = template.Library()
 
+# Query parameters which indicate that a URL has been cryptographically signed by the storage
+# backend. Parameters must not be appended to such URLs, as doing so invalidates the signature.
+SIGNED_URL_PARAMS = (
+    'signature',         # AWS signature v2; Google Cloud Storage v2
+    'x-amz-signature',   # AWS signature v4 (also MinIO, Ceph, Garage, Cloudflare R2, et al.)
+    'x-goog-signature',  # Google Cloud Storage v4
+)
+
 
 @register.inclusion_tag('builtins/tag.html')
 def tag(value, viewname=None):
@@ -159,6 +167,11 @@ def static_with_params(path, **params):
     parameter conflicts. A warning will be logged if any of the provided parameters
     conflict with existing parameters in the URL.
 
+    URLs which have been cryptographically signed by the storage backend (e.g. S3 presigned
+    URLs) are returned unmodified, as appending parameters to them would invalidate their
+    signature. Such URLs embed an expiration and are regenerated on each request, so they
+    require no cache-busting parameters.
+
     Args:
         path: The static file path (e.g., 'setmode.js')
         **params: Query parameters to append (e.g., v='4.3.1')
@@ -170,6 +183,8 @@ def static_with_params(path, **params):
         If any provided parameters conflict with existing URL parameters, a warning
         will be logged and the new parameter value will override the existing one.
     """
+    logger = logging.getLogger('netbox.utilities.templatetags.tags')
+
     # Get the base static URL
     static_url = static(path)
 
@@ -177,8 +192,17 @@ def static_with_params(path, **params):
     parsed = urlparse(static_url)
     existing_params = parse_qs(parsed.query)
 
+    # If the storage backend has signed the URL, return it as-is. Signature schemes such as AWS
+    # signature v4 cover the entire query string, so appending a parameter here would invalidate
+    # the signature and the request would be rejected by the storage backend.
+    if signature_params := [p for p in existing_params if p.lower() in SIGNED_URL_PARAMS]:
+        logger.debug(
+            f"Static URL '{static_url}' is signed ({', '.join(signature_params)}); "
+            f"omitting parameters {tuple(params)}"
+        )
+        return static_url
+
     # Check for duplicate parameters and log warnings
-    logger = logging.getLogger('netbox.utilities.templatetags.tags')
     for key, value in params.items():
         if key in existing_params:
             logger.warning(

+ 68 - 0
netbox/utilities/tests/test_templatetags.py

@@ -106,6 +106,74 @@ class StaticWithParamsTestCase(TestCase):
                 self.assertIn('v=new_version', result)
                 self.assertNotIn('v=old_version', result)
 
+    @override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
+    def test_static_with_params_sigv4_presigned_url(self):
+        """Test that parameters are not appended to an AWS signature v4 presigned URL."""
+        signed_url = (
+            'https://s3.example.com/netbox/static/test.js'
+            '?X-Amz-Algorithm=AWS4-HMAC-SHA256'
+            '&X-Amz-Credential=ABC123%2F20260827%2Fus-east-1%2Fs3%2Faws4_request'
+            '&X-Amz-Date=20260827T141543Z'
+            '&X-Amz-Expires=3600'
+            '&X-Amz-SignedHeaders=host'
+            '&X-Amz-Signature=7a5af16a67d2bc7dc15b77fab733cafdc1344414d884fcf2e0b997b0b78dabca'
+        )
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = signed_url
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            # The signed URL must be returned verbatim: appending a parameter would be included in
+            # the signature calculation performed by the storage backend, invalidating the signature.
+            self.assertEqual(result, signed_url)
+            self.assertNotIn('v=1.0.0', result)
+
+    @override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
+    def test_static_with_params_sigv2_presigned_url(self):
+        """Test that parameters are not appended to an AWS signature v2 presigned URL."""
+        signed_url = (
+            'https://s3.example.com/netbox/static/test.js'
+            '?AWSAccessKeyId=ABC123&Signature=hR9%2F5pRTOWo%3D&Expires=1748635659'
+        )
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = signed_url
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            self.assertEqual(result, signed_url)
+            self.assertNotIn('v=1.0.0', result)
+
+    @override_settings(STATIC_URL='https://storage.example.com/netbox/static/')
+    def test_static_with_params_gcs_presigned_url(self):
+        """Test that parameters are not appended to a Google Cloud Storage v4 signed URL."""
+        signed_url = (
+            'https://storage.example.com/netbox/static/test.js'
+            '?X-Goog-Algorithm=GOOG4-RSA-SHA256'
+            '&X-Goog-Credential=netbox%40example.iam.gserviceaccount.com%2F20260827%2Fauto%2Fstorage'
+            '%2Fgoog4_request'
+            '&X-Goog-Date=20260827T141543Z'
+            '&X-Goog-Expires=3600'
+            '&X-Goog-SignedHeaders=host'
+            '&X-Goog-Signature=4bd3a1f0'
+        )
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = signed_url
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            self.assertEqual(result, signed_url)
+            self.assertNotIn('v=1.0.0', result)
+
+    @override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
+    def test_static_with_params_unsigned_s3_url(self):
+        """Test that parameters are appended to an unsigned (public bucket) S3 URL."""
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = 'https://s3.example.com/netbox/static/test.js'
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            self.assertEqual(result, 'https://s3.example.com/netbox/static/test.js?v=1.0.0')
+
 
 class BadgeTestCase(TestCase):
     """