From f91e0e9fb38ed9bdf4fef5471f432beaca688893 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 27 Aug 2026 10:48:10 -0400 Subject: [PATCH 1/2] 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 --- .../utilities/templatetags/builtins/tags.py | 26 ++++++- netbox/utilities/tests/test_templatetags.py | 68 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 289fb6c5f..7186607dc 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( + 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( 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): """ From db859c11b71aa622ffb76262e124ebdcdb0bc66a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 27 Aug 2026 11:01:08 -0400 Subject: [PATCH 2/2] Defer debug log formatting in static_with_params() Co-Authored-By: Claude Opus 5 --- netbox/utilities/templatetags/builtins/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 7186607dc..f8763c8b2 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -197,8 +197,8 @@ def static_with_params(path, **params): # 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)}" + "Static URL '%s' is signed (%s); omitting parameters %s", + static_url, ', '.join(signature_params), tuple(params) ) return static_url