From 9fd17d6eafe8c32b6866664436385c7478b207ff Mon Sep 17 00:00:00 2001 From: d9pouces Date: Fri, 5 Jul 2024 12:52:53 +0200 Subject: [PATCH 1/3] Allow to integrate subresource-integrity attributes to javascript and stylesheet tags. Require to add the two following keys beside "output_filename" in Django's setting PIPELINE['JAVASCRIPT']["your package"] and PIPELINE['STYLESHEETS']["your package"] and : "crossorigin": "anonymous", "integrity": "sha384", Of course, "sha256" and "sha512" also works. Hashes are computed at runtime and cached to minimize code changes. Cf. https://infosec.mozilla.org/guidelines/web_security#subresource-integrity --- docs/configuration.rst | 19 +++ pipeline/jinja2/__init__.py | 9 +- pipeline/jinja2/pipeline/css.jinja | 2 +- pipeline/jinja2/pipeline/js.jinja | 2 +- pipeline/packager.py | 18 +++ pipeline/templates/pipeline/css.html | 2 +- pipeline/templates/pipeline/css.jinja | 2 +- pipeline/templates/pipeline/js.html | 2 +- pipeline/templates/pipeline/js.jinja | 2 +- pipeline/templatetags/pipeline.py | 4 + tests/settings.py | 76 +++++++++++ tests/tests/test_template.py | 189 ++++++++++++++++++++++++++ tox.ini | 1 + 13 files changed, 321 insertions(+), 7 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 13c807c9..a1ac1b24 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -132,6 +132,25 @@ A dictionary passed to compiler's ``compile_file`` method as kwargs. None of def Defaults to ``{}``. +``crossorigin`` +............... + +**Optional** + +Indicate if you want to add to the group this attribute that provides support for CORS, defining how the element handles cross-origin requests, thereby enabling the configuration of the CORS requests for the element's fetched data. . + +Missing by default (the attribute is not added), the only valid values currently are ``anonymous`` and ``use-credentials``. + +``integrity`` +............. + +**Optional** + +Indicate if you want to add the sub-resource integrity (SRI) attribute to the group. +This attribute contains inline metadata that a user agent can use to verify that a fetched resource has been delivered free of unexpected manipulation + +Missing by default, and only valid values are ``"sha256"``, ``"sha384"`` and ``"sha512"``. + Other settings -------------- diff --git a/pipeline/jinja2/__init__.py b/pipeline/jinja2/__init__.py index 827003a2..038f6aa1 100644 --- a/pipeline/jinja2/__init__.py +++ b/pipeline/jinja2/__init__.py @@ -42,7 +42,12 @@ def render_css(self, package, path): template_name = package.template_name or "pipeline/css.jinja" context = package.extra_context context.update( - {"type": guess_type(path, "text/css"), "url": staticfiles_storage.url(path)} + { + "type": guess_type(path, "text/css"), + "url": staticfiles_storage.url(path), + "crossorigin": package.config.get("crossorigin"), + "integrity": package.get_sri(path), + } ) template = self.environment.get_template(template_name) return template.render(context) @@ -66,6 +71,8 @@ def render_js(self, package, path): { "type": guess_type(path, "text/javascript"), "url": staticfiles_storage.url(path), + "crossorigin": package.config.get("crossorigin"), + "integrity": package.get_sri(path), } ) template = self.environment.get_template(template_name) diff --git a/pipeline/jinja2/pipeline/css.jinja b/pipeline/jinja2/pipeline/css.jinja index d40e7dc0..508f2add 100644 --- a/pipeline/jinja2/pipeline/css.jinja +++ b/pipeline/jinja2/pipeline/css.jinja @@ -1 +1 @@ - + diff --git a/pipeline/jinja2/pipeline/js.jinja b/pipeline/jinja2/pipeline/js.jinja index 6d3c8d49..7e8e4003 100644 --- a/pipeline/jinja2/pipeline/js.jinja +++ b/pipeline/jinja2/pipeline/js.jinja @@ -1 +1 @@ - + diff --git a/pipeline/packager.py b/pipeline/packager.py index 20f34197..4e671822 100644 --- a/pipeline/packager.py +++ b/pipeline/packager.py @@ -1,3 +1,7 @@ +import base64 +import hashlib +from functools import lru_cache + from django.contrib.staticfiles.finders import find, get_finders from django.contrib.staticfiles.storage import staticfiles_storage from django.core.files.base import ContentFile @@ -61,6 +65,20 @@ def manifest(self): def compiler_options(self): return self.config.get("compiler_options", {}) + @lru_cache + def get_sri(self, path): + method = self.config.get("integrity") + if method not in {"sha256", "sha384", "sha512"}: + return None + if staticfiles_storage.exists(path): + with staticfiles_storage.open(path) as fd: + h = getattr(hashlib, method)() + for data in iter(lambda: fd.read(16384), b""): + h.update(data) + digest = base64.b64encode(h.digest()).decode() + return f"{method}-{digest}" + return None + class Packager: def __init__( diff --git a/pipeline/templates/pipeline/css.html b/pipeline/templates/pipeline/css.html index 4321d63e..8e3a558b 100644 --- a/pipeline/templates/pipeline/css.html +++ b/pipeline/templates/pipeline/css.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/pipeline/templates/pipeline/css.jinja b/pipeline/templates/pipeline/css.jinja index d40e7dc0..508f2add 100644 --- a/pipeline/templates/pipeline/css.jinja +++ b/pipeline/templates/pipeline/css.jinja @@ -1 +1 @@ - + diff --git a/pipeline/templates/pipeline/js.html b/pipeline/templates/pipeline/js.html index 29263f72..ff11b246 100644 --- a/pipeline/templates/pipeline/js.html +++ b/pipeline/templates/pipeline/js.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/pipeline/templates/pipeline/js.jinja b/pipeline/templates/pipeline/js.jinja index 6d3c8d49..7e8e4003 100644 --- a/pipeline/templates/pipeline/js.jinja +++ b/pipeline/templates/pipeline/js.jinja @@ -1 +1 @@ - + diff --git a/pipeline/templatetags/pipeline.py b/pipeline/templatetags/pipeline.py index 3f38cca1..6116d6a9 100644 --- a/pipeline/templatetags/pipeline.py +++ b/pipeline/templatetags/pipeline.py @@ -152,6 +152,8 @@ def render_css(self, package, path): { "type": guess_type(path, "text/css"), "url": mark_safe(staticfiles_storage.url(path)), + "crossorigin": package.config.get("crossorigin"), + "integrity": package.get_sri(path), } ) return render_to_string(template_name, context) @@ -188,6 +190,8 @@ def render_js(self, package, path): { "type": guess_type(path, "text/javascript"), "url": mark_safe(staticfiles_storage.url(path)), + "crossorigin": package.config.get("crossorigin"), + "integrity": package.get_sri(path), } ) return render_to_string(template_name, context) diff --git a/tests/settings.py b/tests/settings.py index 265b8664..7b5cc237 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -89,6 +89,42 @@ def local_path(path): "title": "Default Style", }, }, + "screen_crossorigin": { + "source_filenames": ( + "pipeline/css/first.css", + "pipeline/css/second.css", + "pipeline/css/urls.css", + ), + "output_filename": "screen_crossorigin.css", + "crossorigin": "anonymous", + }, + "screen_sri_sha256": { + "source_filenames": ( + "pipeline/css/first.css", + "pipeline/css/second.css", + "pipeline/css/urls.css", + ), + "output_filename": "screen_sri_sha256.css", + "integrity": "sha256", + }, + "screen_sri_sha384": { + "source_filenames": ( + "pipeline/css/first.css", + "pipeline/css/second.css", + "pipeline/css/urls.css", + ), + "output_filename": "screen_sri_sha384.css", + "integrity": "sha384", + }, + "screen_sri_sha512": { + "source_filenames": ( + "pipeline/css/first.css", + "pipeline/css/second.css", + "pipeline/css/urls.css", + ), + "output_filename": "screen_sri_sha512.css", + "integrity": "sha512", + }, }, "JAVASCRIPT": { "scripts": { @@ -137,6 +173,46 @@ def local_path(path): "defer": True, }, }, + "scripts_crossorigin": { + "source_filenames": ( + "pipeline/js/first.js", + "pipeline/js/second.js", + "pipeline/js/application.js", + "pipeline/templates/**/*.jst", + ), + "output_filename": "scripts_crossorigin.js", + "crossorigin": "anonymous", + }, + "scripts_sri_sha256": { + "source_filenames": ( + "pipeline/js/first.js", + "pipeline/js/second.js", + "pipeline/js/application.js", + "pipeline/templates/**/*.jst", + ), + "output_filename": "scripts_sha256.js", + "integrity": "sha256", + }, + "scripts_sri_sha384": { + "source_filenames": ( + "pipeline/js/first.js", + "pipeline/js/second.js", + "pipeline/js/application.js", + "pipeline/templates/**/*.jst", + ), + "output_filename": "scripts_sha384.js", + "integrity": "sha384", + }, + "scripts_sri_sha512": { + "source_filenames": ( + "pipeline/js/first.js", + "pipeline/js/second.js", + "pipeline/js/application.js", + "pipeline/templates/**/*.jst", + ), + "output_filename": "scripts_sha512.js", + "integrity": "sha512", + }, }, } diff --git a/tests/tests/test_template.py b/tests/tests/test_template.py index 0bb17669..f383a8df 100644 --- a/tests/tests/test_template.py +++ b/tests/tests/test_template.py @@ -1,3 +1,8 @@ +import base64 +import hashlib + +from django.contrib.staticfiles.storage import staticfiles_storage +from django.core.management import call_command from django.template import Context, Template from django.test import TestCase from jinja2 import Environment, PackageLoader @@ -8,6 +13,8 @@ class JinjaTest(TestCase): def setUp(self): + staticfiles_storage._setup() + call_command("collectstatic", verbosity=0, interactive=False) self.env = Environment( extensions=[PipelineExtension], loader=PackageLoader("pipeline", "templates"), @@ -64,8 +71,97 @@ def test_package_js_async_defer(self): template.render(), ) + def test_crossorigin(self): + template = self.env.from_string("""{% javascript "scripts_crossorigin" %}""") + self.assertEqual( + ( + '' + ), + template.render(), + ) # noqa + template = self.env.from_string("""{% stylesheet "screen_crossorigin" %}""") + self.assertEqual( + ( + '' + ), + template.render(), + ) # noqa + + def test_sri_sha256(self): + template = self.env.from_string("""{% javascript "scripts_sri_sha256" %}""") + hash_ = self.get_integrity("scripts_sha256.js", "sha256") + self.assertEqual( + ( + '' + ), + template.render(), + ) # noqa + template = self.env.from_string("""{% stylesheet "screen_sri_sha256" %}""") + hash_ = self.get_integrity("screen_sri_sha256.css", "sha256") + self.assertEqual( + ( + f'' + ), + template.render(), + ) # noqa + + def test_sri_sha384(self): + template = self.env.from_string("""{% javascript "scripts_sri_sha384" %}""") + hash_ = self.get_integrity("scripts_sha384.js", "sha384") + self.assertEqual( + ( + '' + ) + % hash_, + template.render(), + ) # noqa + template = self.env.from_string("""{% stylesheet "screen_sri_sha384" %}""") + hash_ = self.get_integrity("screen_sri_sha384.css", "sha384") + self.assertEqual( + ( + f'' + ), + template.render(), + ) # noqa + + def test_sri_sha512(self): + template = self.env.from_string("""{% javascript "scripts_sri_sha512" %}""") + hash_ = self.get_integrity("scripts_sha512.js", "sha512") + self.assertEqual( + ( + '' + ) + % hash_, + template.render(), + ) # noqa + template = self.env.from_string("""{% stylesheet "screen_sri_sha512" %}""") + hash_ = self.get_integrity("screen_sri_sha512.css", "sha512") + self.assertEqual( + ( + f'' + ), + template.render(), + ) # noqa + + @staticmethod + def get_integrity(path, method): + with staticfiles_storage.open(path) as fd: + h = getattr(hashlib, method)(fd.read()) + digest = base64.b64encode(h.digest()).decode() + return f"{method}-{digest}" + class DjangoTest(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + staticfiles_storage._setup() + call_command("collectstatic", verbosity=0, interactive=False) + def render_template(self, template): return Template(template).render(Context()) @@ -137,3 +233,96 @@ def test_compressed_js_async_defer(self): '', # noqa rendered, ) + + def test_crossorigin(self): + rendered = self.render_template( + """{% load pipeline %}{% javascript "scripts_crossorigin" %}""" + ) # noqa + self.assertEqual( + ( + '' + ), + rendered, + ) # noqa + rendered = self.render_template( + """{% load pipeline %}{% stylesheet "screen_crossorigin" %}""" + ) # noqa + self.assertEqual( + ( + '' + ), + rendered, + ) # noqa + + def test_sri_sha256(self): + rendered = self.render_template( + """{% load pipeline %}{% javascript "scripts_sri_sha256" %}""" + ) # noqa + hash_ = JinjaTest.get_integrity("scripts_sha256.js", "sha256") + self.assertEqual( + ( + '' + ) + % hash_, + rendered, + ) # noqa + rendered = self.render_template( + """{% load pipeline %}{% stylesheet "screen_sri_sha256" %}""" + ) # noqa + hash_ = JinjaTest.get_integrity("screen_sri_sha256.css", "sha256") + self.assertEqual( + ( + f'' + ), + rendered, + ) # noqa + + def test_sri_sha384(self): + rendered = self.render_template( + """{% load pipeline %}{% javascript "scripts_sri_sha384" %}""" + ) # noqa + hash_ = JinjaTest.get_integrity("scripts_sha384.js", "sha384") + self.assertEqual( + ( + '' + ) + % hash_, + rendered, + ) # noqa + rendered = self.render_template( + """{% load pipeline %}{% stylesheet "screen_sri_sha384" %}""" + ) # noqa + hash_ = JinjaTest.get_integrity("screen_sri_sha384.css", "sha384") + self.assertEqual( + ( + f'' + ), + rendered, + ) # noqa + + def test_sri_sha512(self): + rendered = self.render_template( + """{% load pipeline %}{% javascript "scripts_sri_sha512" %}""" + ) # noqa + hash_ = JinjaTest.get_integrity("scripts_sha512.js", "sha512") + self.assertEqual( + ( + '' + ) + % hash_, + rendered, + ) # noqa + rendered = self.render_template( + """{% load pipeline %}{% stylesheet "screen_sri_sha512" %}""" + ) # noqa + hash_ = JinjaTest.get_integrity("screen_sri_sha512.css", "sha512") + self.assertEqual( + ( + f'' + ), + rendered, + ) # noqa diff --git a/tox.ini b/tox.ini index c2910b33..554f07d8 100644 --- a/tox.ini +++ b/tox.ini @@ -48,6 +48,7 @@ deps = setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} + JAVA_HOME = /usr/local/Cellar/openjdk/22.0.1 commands = npm install {envbindir}/coverage run --source pipeline {envbindir}/django-admin test {posargs:tests} From 2a18b34f520986c71a3bb1c5b391da3c53f72779 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 10:53:27 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pipeline/jinja2/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipeline/jinja2/__init__.py b/pipeline/jinja2/__init__.py index 038f6aa1..e954784e 100644 --- a/pipeline/jinja2/__init__.py +++ b/pipeline/jinja2/__init__.py @@ -47,7 +47,7 @@ def render_css(self, package, path): "url": staticfiles_storage.url(path), "crossorigin": package.config.get("crossorigin"), "integrity": package.get_sri(path), - } + } ) template = self.environment.get_template(template_name) return template.render(context) From 30d7d2ccfc73c473717c6463d427b46a77919ef8 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Fri, 5 Jul 2024 12:56:50 +0200 Subject: [PATCH 3/3] style: pass the precommit checks --- tests/tests/test_template.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/tests/test_template.py b/tests/tests/test_template.py index f383a8df..4c4a6e92 100644 --- a/tests/tests/test_template.py +++ b/tests/tests/test_template.py @@ -83,7 +83,8 @@ def test_crossorigin(self): template = self.env.from_string("""{% stylesheet "screen_crossorigin" %}""") self.assertEqual( ( - '' + '' ), template.render(), ) # noqa @@ -102,7 +103,8 @@ def test_sri_sha256(self): hash_ = self.get_integrity("screen_sri_sha256.css", "sha256") self.assertEqual( ( - f'' + f'' ), template.render(), ) # noqa @@ -122,7 +124,8 @@ def test_sri_sha384(self): hash_ = self.get_integrity("screen_sri_sha384.css", "sha384") self.assertEqual( ( - f'' + f'' ), template.render(), ) # noqa @@ -142,7 +145,8 @@ def test_sri_sha512(self): hash_ = self.get_integrity("screen_sri_sha512.css", "sha512") self.assertEqual( ( - f'' + f'' ), template.render(), ) # noqa @@ -250,7 +254,8 @@ def test_crossorigin(self): ) # noqa self.assertEqual( ( - '' + '' ), rendered, ) # noqa @@ -274,7 +279,8 @@ def test_sri_sha256(self): hash_ = JinjaTest.get_integrity("screen_sri_sha256.css", "sha256") self.assertEqual( ( - f'' + f'' ), rendered, ) # noqa @@ -298,7 +304,8 @@ def test_sri_sha384(self): hash_ = JinjaTest.get_integrity("screen_sri_sha384.css", "sha384") self.assertEqual( ( - f'' + f'' ), rendered, ) # noqa @@ -322,7 +329,8 @@ def test_sri_sha512(self): hash_ = JinjaTest.get_integrity("screen_sri_sha512.css", "sha512") self.assertEqual( ( - f'' + f'' ), rendered, ) # noqa