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:
@@ -6,7 +6,7 @@
|
|||||||
<div class="tmf-page"
|
<div class="tmf-page"
|
||||||
x-data="{
|
x-data="{
|
||||||
recipes: window.__recipeListData,
|
recipes: window.__recipeListData,
|
||||||
search: '{{ search or '' }}',
|
search: {{ (search or '')|tojson_attr }},
|
||||||
filter: 'all',
|
filter: 'all',
|
||||||
deleteModal: false,
|
deleteModal: false,
|
||||||
deleteTarget: null,
|
deleteTarget: null,
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ _('Aggiornata') }}</span>
|
<span>{{ _('Aggiornata') }}</span>
|
||||||
<span class="font-medium" x-text="new Date(recipe.current_version.created_at).toLocaleDateString('{{ current_language or 'it' }}')"></span>
|
<span class="font-medium" x-text="new Date(recipe.current_version.created_at).toLocaleDateString({{ (current_language or 'it')|tojson_attr }})"></span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<div class="tmf-page"
|
<div class="tmf-page"
|
||||||
x-data="{
|
x-data="{
|
||||||
recipes: window.__selectRecipeData,
|
recipes: window.__selectRecipeData,
|
||||||
search: '{{ auto_recipe_code }}',
|
search: {{ auto_recipe_code|tojson_attr }},
|
||||||
lot_number: '{{ auto_lot }}',
|
lot_number: {{ auto_lot|tojson_attr }},
|
||||||
serial_number: '{{ auto_serial }}',
|
serial_number: {{ auto_serial|tojson_attr }},
|
||||||
barcodeModal: false,
|
barcodeModal: false,
|
||||||
barcodeInput: '',
|
barcodeInput: '',
|
||||||
barcodeLoading: false,
|
barcodeLoading: false,
|
||||||
|
|||||||
@@ -27,6 +27,40 @@ class TestRecipeList:
|
|||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert "/auth/login" in resp.headers["Location"]
|
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:
|
class TestRecipeNew:
|
||||||
"""GET /maker/recipes/new tests."""
|
"""GET /maker/recipes/new tests."""
|
||||||
|
|||||||
@@ -32,6 +32,69 @@ class TestSelectRecipe:
|
|||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert "/auth/login" in resp.headers["Location"]
|
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: "L\\u002723"" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
class TestTaskList:
|
class TestTaskList:
|
||||||
"""GET /measure/tasks/<recipe_id> tests."""
|
"""GET /measure/tasks/<recipe_id> tests."""
|
||||||
|
|||||||
Reference in New Issue
Block a user