Merge pull request #23051 from netbox-community/23038-fix-signed-S3-URLs

Fixes #23038: Don't append cache-busting parameter to signed static file URLs
This commit is contained in:
bctiemann 2026-08-27 18:12:53 -04:00 committed by GitHub
commit 50290b53e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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(
"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(

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