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 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 21:23:40 +02:00
parent 11142c1d08
commit 96bbd0c136
No known key found for this signature in database
3 changed files with 84 additions and 51 deletions

View file

@ -8,3 +8,29 @@
<div class="config-field-value">{{ value }}</div>
</div>
{% 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) %}
<div class="config-field">
<div class="config-field-label">{{ label }}</div>
<div class="config-field-value">{{ caller() }}</div>
</div>
{% 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) %}
<form hx-post="/servers/{{ server.id }}/console"
hx-target="#{{ transcript_id }}"
hx-swap="beforeend"
hx-on::after-request="this.command.value=''; this.command.focus()"
class="console-input-form"
data-console-form data-server-id="{{ server.id }}">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<span class="console-prompt-glyph">&gt;</span>
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
placeholder="status, changelevel c1m1_hotel, sm_kick …">
<button type="submit">Send</button>
</form>
{% endmacro %}

View file

@ -18,35 +18,33 @@
hx-trigger="load, every 5s"
hx-swap="innerHTML"></section>
{# 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 #}
<div class="config-grid">
{{ macros.config_field(
"Port",
('<a href="steam://run/550//+connect%20' ~ connect_host ~ ':' ~ server.port ~ '">' ~ server.port ~ '</a>') | safe
) }}
{{ macros.config_field(
"Blueprint",
(('<a href="/blueprints/' ~ blueprint.id ~ '">' ~ blueprint.name ~ '</a>') | safe) if blueprint else "—"
) }}
{{ macros.config_field(
"RCON",
(
'<span class="password-mask" data-password-field="' ~ server.id ~ '">••••••••••••</span>'
~ '<span class="password-value" data-password-field="' ~ server.id ~ '" hidden>' ~ server.rcon_password ~ '</span> '
~ '<button class="link-button" data-password-toggle="' ~ server.id ~ '" aria-label="Show RCON password">show</button>'
) | safe
) }}
{{ macros.config_field(
"Hostname",
(
'<form method="post" action="/servers/' ~ server.id ~ '" class="inline-save">'
~ '<input type="hidden" name="csrf_token" value="' ~ session.get('csrf_token', '') ~ '">'
~ '<input name="hostname" value="' ~ server.hostname ~ '" placeholder="' ~ g.user.username ~ ' ' ~ server.name ~ '" maxlength="128">'
~ '<button type="submit">Save</button>'
~ '</form>'
) | safe,
editable=True
) }}
{% call macros.config_field_block("Port") %}
<a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a>
{% endcall %}
{% call macros.config_field_block("Blueprint") %}
{% if blueprint %}
<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>
{% else %}
{% endif %}
{% endcall %}
{% call macros.config_field_block("RCON") %}
<span class="password-mask" data-password-field="{{ server.id }}">••••••••••••</span>
<span class="password-value" data-password-field="{{ server.id }}" hidden>{{ server.rcon_password }}</span>
<button class="link-button" data-password-toggle="{{ server.id }}" aria-label="Show RCON password">show</button>
{% endcall %}
{% call macros.config_field_block("Hostname", editable=True) %}
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="hostname" value="{{ server.hostname }}" placeholder="{{ g.user.username }} {{ server.name }}" maxlength="128">
<button type="submit">Save</button>
</form>
{% endcall %}
</div>
</section>
@ -71,18 +69,7 @@
{% endwith %}
{% endfor %}
</div>
<form hx-post="/servers/{{ server.id }}/console"
hx-target="#console-transcript-inline-{{ server.id }}"
hx-swap="beforeend"
hx-on::after-request="this.command.value=''; this.command.focus()"
class="console-input-form"
data-console-form data-server-id="{{ server.id }}">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<span class="console-prompt-glyph">&gt;</span>
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
placeholder="status, changelevel c1m1_hotel, sm_kick …">
<button type="submit">Send</button>
</form>
{{ macros.console_form(server, "console-transcript-inline-" ~ server.id) }}
</div>
<div role="tabpanel" data-tab="files" class="tab-pane" hidden>
@ -128,17 +115,7 @@
{% endwith %}
{% endfor %}
</div>
<form hx-post="/servers/{{ server.id }}/console"
hx-target="#console-transcript-modal-{{ server.id }}"
hx-swap="beforeend"
hx-on::after-request="this.command.value=''; this.command.focus()"
class="console-input-form"
data-console-form data-server-id="{{ server.id }}">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<span class="console-prompt-glyph">&gt;</span>
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000">
<button type="submit">Send</button>
</form>
{{ macros.console_form(server, "console-transcript-modal-" ~ server.id) }}
</div>
</dialog>

View file

@ -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='<script>alert("xss")</script>',
)
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 "<script>alert(" not in body
assert "&lt;script&gt;" in body
def test_server_detail_renders_state_cluster_and_inspection_strip(user_client_with_blueprints) -> None:
from sqlalchemy import select