diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 289fb6c5f..f8763c8b2 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/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( + "Static URL '%s' is signed (%s); omitting parameters %s", + static_url, ', '.join(signature_params), 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( diff --git a/netbox/utilities/tests/test_templatetags.py b/netbox/utilities/tests/test_templatetags.py index 7da38f163..ea41b10ee 100644 --- a/netbox/utilities/tests/test_templatetags.py +++ b/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): """