fix(xss): dati query in x-data passano da tojson_attr, non interpolazione JS (#10)

select_recipe.html e recipe_list.html interpolavano recipe/lot/serial/search
dentro un letterale JS con apici singoli: un valore con `'` chiudeva la
stringa e iniettava chiavi nell'oggetto x-data (CSP con unsafe-eval).
Anche recipe_list.html:222 (toLocaleDateString) aveva lo stesso schema su
current_language. Fix: |tojson_attr ovunque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:29:05 +02:00
parent 920ab2fb73
commit 51f3fe32d4
4 changed files with 102 additions and 5 deletions
@@ -27,6 +27,40 @@ class TestRecipeList:
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
def test_recipe_list_search_does_not_break_out_of_x_data(
self, logged_in_client, mock_api_client
):
"""#10: ``?search=`` is JSON-encoded inside x-data, not string-interpolated.
The payload must land as the *content* of a single quoted JSON
string token (recovered by undoing only the ``\\u0027`` escape),
and the object key right after it must still be the untouched
``filter: 'all'`` from the template - proof no new key was injected.
"""
import re
from html.parser import HTMLParser
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
payload = "',x:alert(1),y:'"
resp = logged_in_client.get("/maker/recipes", query_string={"search": payload})
assert resp.status_code == 200
html = resp.get_data(as_text=True)
captured = {}
class _P(HTMLParser):
def handle_starttag(self, tag, attrs):
d = dict(attrs)
if d.get("x-data") and "search:" in d["x-data"]:
captured["x_data"] = d["x-data"]
_P().feed(html)
assert "x_data" in captured
m = re.search(r'search:\s*"([^"]*)"\s*,\s*filter:\s*\'all\'', captured["x_data"])
assert m, f"search is not a single quoted JSON token: {captured['x_data']!r}"
assert m.group(1).replace("\\u0027", "'") == payload
class TestRecipeNew:
"""GET /maker/recipes/new tests."""
@@ -32,6 +32,69 @@ class TestSelectRecipe:
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
def test_select_recipe_query_params_do_not_break_out_of_x_data(
self, logged_in_client, mock_api_client, monkeypatch
):
"""#10: recipe/lot query params are JSON-encoded inside x-data.
A raw ``'{{ auto_lot }}'`` interpolation lets a value containing a
single quote close the JS string literal and inject new keys into
the Alpine ``x-data`` object (reflected XSS, CSP has unsafe-eval).
The fix is ``|tojson_attr``: the payload must survive only as the
*content* of a single quoted JSON string token, never as JS syntax
that opens a new object key (``lot_number:`` must directly follow).
"""
import re
from html.parser import HTMLParser
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = []
payload = "',x:alert(document.domain),y:'"
resp = logged_in_client.get("/measure/select", query_string={"recipe": payload})
assert resp.status_code == 200
html = resp.get_data(as_text=True)
captured = {}
class _P(HTMLParser):
def handle_starttag(self, tag, attrs):
d = dict(attrs)
if d.get("x-data") and "search:" in d["x-data"]:
captured["x_data"] = d["x-data"]
_P().feed(html)
assert "x_data" in captured
m = re.search(r'search:\s*"([^"]*)"\s*,\s*lot_number:', captured["x_data"])
assert m, f"search is not a single quoted JSON token: {captured['x_data']!r}"
assert m.group(1).replace("\\u0027", "'") == payload
def test_select_recipe_lot_with_apostrophe_stays_valid_json_string(
self, logged_in_client, mock_api_client, monkeypatch
):
"""#10: a lot number like ``L'23`` must not break the page."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select", query_string={"lot": "L'23"})
assert resp.status_code == 200
# Raw HTML (pre-entity-decoding): the value is a JSON string whose
# delimiting quotes are the HTML entity, and the apostrophe is the
# JS unicode escape - never a bare ``'`` that could end the token.
assert "lot_number: &#34;L\\u002723&#34;" in resp.get_data(as_text=True)
class TestTaskList:
"""GET /measure/tasks/<recipe_id> tests."""