Skip to content

Commit 7ae55d7

Browse files
Normalize paths in asset manifest for enhanced security (#221)
* Normalize paths in asset manifest for enhanced security - Add path normalization to obscure system-specific information in manifest - Convert system paths to avoid leaking environment details - Update tests to account for normalized paths * Update changelog with security enhancement * update changelog * update * move import * make mypy happy
1 parent 82271af commit 7ae55d7

File tree

4 files changed

+113
-12
lines changed

4 files changed

+113
-12
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Security
22+
23+
- Normalize paths in asset manifest to prevent leaking system-specific information like Python version and installation paths
24+
2125
## [0.17.1]
2226

2327
### Fixed

src/django_bird/manifest.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import json
45
import logging
56
from pathlib import Path
@@ -13,6 +14,40 @@
1314
_manifest_cache = None
1415

1516

17+
def normalize_path(path: str) -> str:
18+
"""Normalize a template path to remove system-specific information.
19+
20+
Args:
21+
path: The template path to normalize
22+
23+
Returns:
24+
str: A normalized path without system-specific details
25+
"""
26+
if "site-packages" in path:
27+
parts = path.split("site-packages/")
28+
if len(parts) > 1:
29+
return f"pkg:{parts[1]}"
30+
31+
if hasattr(settings, "BASE_DIR") and settings.BASE_DIR: # type: ignore[misc]
32+
base_dir = Path(settings.BASE_DIR).resolve() # type: ignore[misc]
33+
abs_path = Path(path).resolve()
34+
try:
35+
if str(abs_path).startswith(str(base_dir)):
36+
rel_path = abs_path.relative_to(base_dir)
37+
return f"app:{rel_path}"
38+
except ValueError:
39+
# Path is not relative to BASE_DIR
40+
pass
41+
42+
if path.startswith("/"):
43+
hash_val = hashlib.md5(path.encode()).hexdigest()[:8]
44+
filename = Path(path).name
45+
return f"ext:{hash_val}/{filename}"
46+
47+
# Return as is if it's already a relative path
48+
return path
49+
50+
1651
def load_asset_manifest() -> dict[str, list[str]] | None:
1752
"""Load asset manifest from the default location.
1853
@@ -57,9 +92,12 @@ def generate_asset_manifest() -> dict[str, list[str]]:
5792
dict[str, list[str]]: A dictionary mapping template paths to lists of component names.
5893
"""
5994
template_component_map: dict[str, set[str]] = {}
95+
6096
for template_path, component_names in gather_bird_tag_template_usage():
61-
# Convert Path objects to strings for JSON
62-
template_component_map[str(template_path)] = component_names
97+
# Convert Path objects to strings for JSON and normalize
98+
original_path = str(template_path)
99+
normalized_path = normalize_path(original_path)
100+
template_component_map[normalized_path] = component_names
63101

64102
manifest: dict[str, list[str]] = {
65103
template: sorted(list(components))
@@ -79,8 +117,14 @@ def save_asset_manifest(manifest_data: dict[str, list[str]], path: Path | str) -
79117
path_obj = Path(path)
80118
path_obj.parent.mkdir(parents=True, exist_ok=True)
81119

120+
normalized_manifest = {}
121+
122+
for template_path, components in manifest_data.items():
123+
normalized_path = normalize_path(template_path)
124+
normalized_manifest[normalized_path] = components
125+
82126
with open(path_obj, "w") as f:
83-
json.dump(manifest_data, f, indent=2)
127+
json.dump(normalized_manifest, f, indent=2)
84128

85129

86130
def default_manifest_path() -> Path:

src/django_bird/templatetags/tags/asset.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from django_bird._typing import override
1414
from django_bird.manifest import load_asset_manifest
15+
from django_bird.manifest import normalize_path
1516

1617

1718
class AssetTag(Enum):
@@ -51,9 +52,9 @@ def render(self, context: Context) -> str:
5152
# Only use manifest in production mode
5253
if not settings.DEBUG:
5354
manifest = load_asset_manifest()
54-
if manifest and template_path in manifest:
55-
# Use manifest for component names in production
56-
component_names = manifest[template_path]
55+
normalized_path = normalize_path(template_path)
56+
if manifest and normalized_path in manifest:
57+
component_names = manifest[normalized_path]
5758
used_components = [
5859
components.get_component(name) for name in component_names
5960
]

tests/test_manifest.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django_bird.manifest import default_manifest_path
1313
from django_bird.manifest import generate_asset_manifest
1414
from django_bird.manifest import load_asset_manifest
15+
from django_bird.manifest import normalize_path
1516
from django_bird.manifest import save_asset_manifest
1617
from tests.utils import TestComponent
1718

@@ -139,6 +140,43 @@ def mock_open_with_error(*args, **kwargs):
139140
assert loaded_manifest is None
140141

141142

143+
def test_normalize_path_site_packages():
144+
site_pkg_path = "/usr/local/lib/python3.12/site-packages/django_third_party_pkg/components/templates/bird/button.html"
145+
146+
normalized = normalize_path(site_pkg_path)
147+
148+
assert (
149+
normalized == "pkg:django_third_party_pkg/components/templates/bird/button.html"
150+
)
151+
152+
153+
def test_normalize_path_project_base_dir():
154+
base_dir = "/home/user/project"
155+
project_path = f"{base_dir}/app/templates/invoices/pending_invoice_list.html"
156+
157+
with override_settings(BASE_DIR=base_dir):
158+
normalized = normalize_path(project_path)
159+
160+
assert normalized == "app:app/templates/invoices/pending_invoice_list.html"
161+
162+
163+
def test_normalize_path_other_absolute_dir():
164+
other_path = "/some/random/external/path.html"
165+
166+
normalized = normalize_path(other_path)
167+
168+
assert normalized.startswith("ext:")
169+
assert "path.html" in normalized
170+
171+
172+
def test_normalize_path_relative_dir():
173+
rel_path = "relative/path.html"
174+
175+
normalized = normalize_path(rel_path)
176+
177+
assert normalized == rel_path
178+
179+
142180
def test_generate_asset_manifest(templates_dir, registry):
143181
template1 = templates_dir / "test_manifest1.html"
144182
template1.write_text("""
@@ -181,12 +219,21 @@ def test_generate_asset_manifest(templates_dir, registry):
181219

182220
manifest = generate_asset_manifest()
183221

184-
all_keys = list(manifest.keys())
185-
template1_key = [k for k in all_keys if str(template1) in k][0]
186-
template2_key = [k for k in all_keys if str(template2) in k][0]
222+
for key in manifest.keys():
223+
# Keys should not be absolute paths starting with /
224+
assert not key.startswith("/"), f"Found absolute path in manifest: {key}"
225+
226+
template1_components = []
227+
template2_components = []
228+
229+
for key, components in manifest.items():
230+
if "test_manifest1.html" in key:
231+
template1_components = components
232+
elif "test_manifest2.html" in key:
233+
template2_components = components
187234

188-
assert sorted(manifest[template1_key]) == sorted(["button", "card"])
189-
assert sorted(manifest[template2_key]) == sorted(["accordion", "tab"])
235+
assert sorted(template1_components) == sorted(["button", "card"])
236+
assert sorted(template2_components) == sorted(["accordion", "tab"])
190237

191238

192239
def test_save_and_load_asset_manifest(tmp_path):
@@ -195,6 +242,11 @@ def test_save_and_load_asset_manifest(tmp_path):
195242
"/path/to/template2.html": ["accordion", "tab"],
196243
}
197244

245+
expected_normalized_data = {
246+
normalize_path("/path/to/template1.html"): ["button", "card"],
247+
normalize_path("/path/to/template2.html"): ["accordion", "tab"],
248+
}
249+
198250
output_path = tmp_path / "test-manifest.json"
199251

200252
save_asset_manifest(test_manifest_data, output_path)
@@ -204,7 +256,7 @@ def test_save_and_load_asset_manifest(tmp_path):
204256
with open(output_path) as f:
205257
loaded_data = json.load(f)
206258

207-
assert loaded_data == test_manifest_data
259+
assert loaded_data == expected_normalized_data
208260

209261

210262
def test_default_manifest_path():

0 commit comments

Comments
 (0)