From 96bbd0c13641b0af789bb3bb8c015250b2fe180b Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 21:23:40 +0200 Subject: [PATCH] fix(server-detail): restore auto-escape via macro-call blocks + extract console_form macro Replace four raw-string | safe config_field calls with {% call config_field_block %} blocks so Jinja auto-escaping is preserved for server.hostname, server.name, blueprint.name, server.rcon_password and g.user.username. Extract a console_form macro to eliminate the duplicated inline/modal form and restore the missing placeholder on the modal input. Add XSS regression test that confirms the fix is load-bearing (test fails when templates are reverted to pre-fix state). Co-Authored-By: Claude Sonnet 4.6 --- l4d2web/l4d2web/templates/_macros.html | 26 +++++++ l4d2web/l4d2web/templates/server_detail.html | 79 +++++++------------- l4d2web/tests/test_servers.py | 30 ++++++++ 3 files changed, 84 insertions(+), 51 deletions(-) diff --git a/l4d2web/l4d2web/templates/_macros.html b/l4d2web/l4d2web/templates/_macros.html index b78ed87..5473387 100644 --- a/l4d2web/l4d2web/templates/_macros.html +++ b/l4d2web/l4d2web/templates/_macros.html @@ -8,3 +8,29 @@
{{ value }}
{% endmacro %} + +{# Block form of config_field: lets the caller write the value as a Jinja + template body, which keeps auto-escaping active for interpolated values. #} +{% macro config_field_block(label, editable=False) %} +
+
{{ label }}
+
{{ caller() }}
+
+{% endmacro %} + +{# Console form — used by both the inline tab and the modal. The transcript + ID differs per location to keep HTMX swaps independent. #} +{% macro console_form(server, transcript_id) %} +
+ + > + + +
+{% endmacro %} diff --git a/l4d2web/l4d2web/templates/server_detail.html b/l4d2web/l4d2web/templates/server_detail.html index c567a65..78094fc 100644 --- a/l4d2web/l4d2web/templates/server_detail.html +++ b/l4d2web/l4d2web/templates/server_detail.html @@ -18,35 +18,33 @@ hx-trigger="load, every 5s" hx-swap="innerHTML"> - {# Config grid — flat auto-fit; uses config_field macro from _macros.html #} + {# Config grid — flat auto-fit; uses config_field_block macro from _macros.html #}
- {{ macros.config_field( - "Port", - ('' ~ server.port ~ '') | safe - ) }} - {{ macros.config_field( - "Blueprint", - (('' ~ blueprint.name ~ '') | safe) if blueprint else "—" - ) }} - {{ macros.config_field( - "RCON", - ( - '••••••••••••' - ~ ' ' - ~ '' - ) | safe - ) }} - {{ macros.config_field( - "Hostname", - ( - '
' - ~ '' - ~ '' - ~ '' - ~ '
' - ) | safe, - editable=True - ) }} + {% call macros.config_field_block("Port") %} + {{ server.port }} + {% endcall %} + + {% call macros.config_field_block("Blueprint") %} + {% if blueprint %} + {{ blueprint.name }} + {% else %} + — + {% endif %} + {% endcall %} + + {% call macros.config_field_block("RCON") %} + •••••••••••• + + + {% endcall %} + + {% call macros.config_field_block("Hostname", editable=True) %} +
+ + + +
+ {% endcall %}
@@ -71,18 +69,7 @@ {% endwith %} {% endfor %} -
- - > - - -
+ {{ macros.console_form(server, "console-transcript-inline-" ~ server.id) }} -
- - > - - -
+ {{ macros.console_form(server, "console-transcript-modal-" ~ server.id) }} diff --git a/l4d2web/tests/test_servers.py b/l4d2web/tests/test_servers.py index a7551cb..0f99fbc 100644 --- a/l4d2web/tests/test_servers.py +++ b/l4d2web/tests/test_servers.py @@ -752,6 +752,36 @@ def test_update_server_clears_hostname(user_client_with_blueprints) -> None: assert server.hostname == "" +def test_server_detail_escapes_hostname(user_client_with_blueprints) -> None: + """Hostname containing HTML must be escaped, not rendered as markup. + Regression guard for the macro-call refactor that ensures Jinja + auto-escaping is preserved on the config grid.""" + from sqlalchemy import select + from l4d2web.models import Server + + client, data = user_client_with_blueprints + + with session_scope() as db: + server = Server( + user_id=data["user_id"], + blueprint_id=data["blueprint_id"], + name="xss-test", + port=27299, + rcon_password="x", + hostname='', + ) + db.add(server) + db.flush() + server_id = server.id + + resp = client.get(f"/servers/{server_id}") + assert resp.status_code == 200 + body = resp.get_data(as_text=True) + # The literal tag must not appear; the escaped form must. + assert "