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:
commit
50290b53e5
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue