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>
This commit is contained in:
Jeremy Stretch 2026-08-27 10:48:10 -04:00
parent c1135de8f5
commit f91e0e9fb3
2 changed files with 93 additions and 1 deletions

View File

@ -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(

View File

@ -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):
"""