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:
parent
11142c1d08
commit
96bbd0c136
3 changed files with 84 additions and 51 deletions
|
|
@ -8,3 +8,29 @@
|
||||||
<div class="config-field-value">{{ value }}</div>
|
<div class="config-field-value">{{ value }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% 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">></span>
|
||||||
|
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
|
||||||
|
placeholder="status, changelevel c1m1_hotel, sm_kick …">
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
|
||||||
|
|
@ -18,35 +18,33 @@
|
||||||
hx-trigger="load, every 5s"
|
hx-trigger="load, every 5s"
|
||||||
hx-swap="innerHTML"></section>
|
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">
|
<div class="config-grid">
|
||||||
{{ macros.config_field(
|
{% call macros.config_field_block("Port") %}
|
||||||
"Port",
|
<a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a>
|
||||||
('<a href="steam://run/550//+connect%20' ~ connect_host ~ ':' ~ server.port ~ '">' ~ server.port ~ '</a>') | safe
|
{% endcall %}
|
||||||
) }}
|
|
||||||
{{ macros.config_field(
|
{% call macros.config_field_block("Blueprint") %}
|
||||||
"Blueprint",
|
{% if blueprint %}
|
||||||
(('<a href="/blueprints/' ~ blueprint.id ~ '">' ~ blueprint.name ~ '</a>') | safe) if blueprint else "—"
|
<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>
|
||||||
) }}
|
{% else %}
|
||||||
{{ macros.config_field(
|
—
|
||||||
"RCON",
|
{% endif %}
|
||||||
(
|
{% endcall %}
|
||||||
'<span class="password-mask" data-password-field="' ~ server.id ~ '">••••••••••••</span>'
|
|
||||||
~ '<span class="password-value" data-password-field="' ~ server.id ~ '" hidden>' ~ server.rcon_password ~ '</span> '
|
{% call macros.config_field_block("RCON") %}
|
||||||
~ '<button class="link-button" data-password-toggle="' ~ server.id ~ '" aria-label="Show RCON password">show</button>'
|
<span class="password-mask" data-password-field="{{ server.id }}">••••••••••••</span>
|
||||||
) | safe
|
<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>
|
||||||
{{ macros.config_field(
|
{% endcall %}
|
||||||
"Hostname",
|
|
||||||
(
|
{% call macros.config_field_block("Hostname", editable=True) %}
|
||||||
'<form method="post" action="/servers/' ~ server.id ~ '" class="inline-save">'
|
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
|
||||||
~ '<input type="hidden" name="csrf_token" value="' ~ session.get('csrf_token', '') ~ '">'
|
<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">'
|
<input name="hostname" value="{{ server.hostname }}" placeholder="{{ g.user.username }} {{ server.name }}" maxlength="128">
|
||||||
~ '<button type="submit">Save</button>'
|
<button type="submit">Save</button>
|
||||||
~ '</form>'
|
</form>
|
||||||
) | safe,
|
{% endcall %}
|
||||||
editable=True
|
|
||||||
) }}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -71,18 +69,7 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<form hx-post="/servers/{{ server.id }}/console"
|
{{ macros.console_form(server, "console-transcript-inline-" ~ server.id) }}
|
||||||
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">></span>
|
|
||||||
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
|
|
||||||
placeholder="status, changelevel c1m1_hotel, sm_kick …">
|
|
||||||
<button type="submit">Send</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="tabpanel" data-tab="files" class="tab-pane" hidden>
|
<div role="tabpanel" data-tab="files" class="tab-pane" hidden>
|
||||||
|
|
@ -128,17 +115,7 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<form hx-post="/servers/{{ server.id }}/console"
|
{{ macros.console_form(server, "console-transcript-modal-" ~ server.id) }}
|
||||||
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">></span>
|
|
||||||
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000">
|
|
||||||
<button type="submit">Send</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -752,6 +752,36 @@ def test_update_server_clears_hostname(user_client_with_blueprints) -> None:
|
||||||
assert server.hostname == ""
|
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 "<script>" in body
|
||||||
|
|
||||||
|
|
||||||
def test_server_detail_renders_state_cluster_and_inspection_strip(user_client_with_blueprints) -> None:
|
def test_server_detail_renders_state_cluster_and_inspection_strip(user_client_with_blueprints) -> None:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue