Skip to content

Commit b7cbc10

Browse files
committed
fix: harden contact card against XSS and fix template defaults
- HTML-escape label/display values in build-time output - Use json.dumps() for safe JS string interpolation - Add esc() DOM helper for runtime innerHTML escaping - Validate URL schemes (reject javascript:, data:, etc.) - Return empty href for non-linkable types (signal_username, discord) - Fix noscript fallback logic to use type-based check - Fix speedtest_url default() to handle empty string (boolean=True) - Move MD003 pragma inside has_migration conditional
1 parent 5333db2 commit b7cbc10

2 files changed

Lines changed: 52 additions & 19 deletions

File tree

plex-guide/docs/index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ This is almost always an internet speed issue. Plex needs a stable connection to
267267

268268
- Move closer to your WiFi router
269269
- Plug your TV into the router with an ethernet cable
270-
- Test your internet speed at [{{ speedtest_url | default("fast.com") }}](https://{{ speedtest_url | default("fast.com") }}) - you need at least 10 Mbps for HD
270+
- Test your internet speed at [{{ speedtest_url | default("fast.com", true) }}](https://{{ speedtest_url | default("fast.com", true) }}) - you need at least 10 Mbps for HD
271271

272272
For more help: [Plex Playback Quality Guide](https://support.plex.tv/articles/quality-suggestions/)
273273

@@ -408,7 +408,7 @@ For more help: [Plex Password Reset Guide](https://support.plex.tv/articles/acco
408408

409409
- Moving closer to your WiFi router
410410
- Plugging your TV into the router with a cable
411-
- Testing your speed at [{{ speedtest_url | default("fast.com") }}](https://{{ speedtest_url | default("fast.com") }}) - you need 25+ Mbps for 4K
411+
- Testing your speed at [{{ speedtest_url | default("fast.com", true) }}](https://{{ speedtest_url | default("fast.com", true) }}) - you need 25+ Mbps for 4K
412412

413413
For more help: [Plex Quality Settings](https://support.plex.tv/articles/quality-suggestions/)
414414

@@ -547,8 +547,8 @@ If you received a message that you're not authorized to use this account, you ne
547547

548548
This is free and takes 2 minutes. See [Getting Started](#getting-started-one-time-setup) above for full setup instructions.
549549

550-
<!-- markdownlint-disable MD003 -->
551550
{% if has_migration %}
551+
<!-- markdownlint-disable MD003 -->
552552
---
553553

554554
## Switching to a New Account?

plex-guide/main.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@
66
"""
77

88
import base64
9+
import html as html_mod
10+
import json
911
import re
1012

1113
from markupsafe import Markup
1214

15+
# Contact types that display as plain text (no clickable link)
16+
_NO_LINK_TYPES = frozenset({"signal_username", "discord"})
17+
18+
# Allowed URL scheme prefixes for href attributes
19+
_ALLOWED_SCHEMES = ("mailto:", "tel:", "sms:", "https://", "http://")
20+
1321

1422
def define_env(env):
1523
"""Register macros environment."""
@@ -49,29 +57,38 @@ def contact_card(admin_name=None, contact_methods=None):
4957

5058
encoded = base64.b64encode(value.encode()).decode("ascii")
5159
elem_id = f"contact-{i}"
60+
safe_label = html_mod.escape(label)
5261
badge = (
5362
' <span class="preferred-badge">preferred</span>' if preferred else ""
5463
)
5564

5665
href = _build_href(method_type, value)
5766
display_value = _display_value(method_type, value)
5867

59-
# noscript fallback with plain value
60-
noscript_link = (
61-
f'<a href="{href}" rel="nofollow noopener noreferrer">{display_value}</a>'
62-
if href != value
63-
else f"<span>{display_value}</span>"
64-
)
68+
# noscript fallback — use type-based check, not href equality
69+
if method_type in _NO_LINK_TYPES:
70+
noscript_content = f"<span>{html_mod.escape(display_value)}</span>"
71+
elif href:
72+
noscript_content = (
73+
f'<a href="{html_mod.escape(href, quote=True)}"'
74+
f' rel="nofollow noopener noreferrer">'
75+
f"{html_mod.escape(display_value)}</a>"
76+
)
77+
else:
78+
noscript_content = f"<span>{html_mod.escape(display_value)}</span>"
6579

6680
items_html.append(
6781
f'<li id="{elem_id}">'
68-
f"<strong>{label}</strong>{badge}: "
69-
f"<noscript>{noscript_link}</noscript>"
82+
f"<strong>{safe_label}</strong>{badge}: "
83+
f"<noscript>{noscript_content}</noscript>"
7084
f"</li>"
7185
)
7286

87+
# Use json.dumps for safe JS string interpolation
7388
js_decoders.append(
74-
f' decode("{elem_id}", "{encoded}", "{label}", "{method_type}", {str(preferred).lower()});'
89+
f" decode({json.dumps(elem_id)}, {json.dumps(encoded)},"
90+
f" {json.dumps(label)}, {json.dumps(method_type)},"
91+
f" {str(preferred).lower()});"
7592
)
7693

7794
if not items_html:
@@ -80,7 +97,7 @@ def contact_card(admin_name=None, contact_methods=None):
8097
items = "\n".join(items_html)
8198
decoders = "\n".join(js_decoders)
8299

83-
html = f"""\
100+
html_out = f"""\
84101
<style>
85102
.contact-card {{
86103
border: 1px solid var(--md-default-fg-color--lighter, #e0e0e0);
@@ -116,6 +133,11 @@ def contact_card(admin_name=None, contact_methods=None):
116133
</div>
117134
<script>
118135
(function() {{
136+
function esc(s) {{
137+
var d = document.createElement('div');
138+
d.appendChild(document.createTextNode(s));
139+
return d.innerHTML;
140+
}}
119141
function decode(id, encoded, label, type, preferred) {{
120142
var elem = document.getElementById(id);
121143
if (!elem) return;
@@ -133,10 +155,10 @@ def contact_card(admin_name=None, contact_methods=None):
133155
href = decoded;
134156
display = label;
135157
}} else if (type === 'signal_username' || type === 'discord') {{
136-
elem.innerHTML = '<strong>' + label + '</strong>' + badge + ': ' + decoded;
158+
elem.innerHTML = '<strong>' + esc(label) + '</strong>' + badge + ': ' + esc(decoded);
137159
return;
138160
}} else if (type === 'telegram') {{
139-
href = 'https://t.me/' + decoded;
161+
href = 'https://t.me/' + encodeURIComponent(decoded);
140162
display = decoded;
141163
}} else if (type === 'whatsapp') {{
142164
href = 'https://wa.me/' + decoded.replace(/[^+\\d]/g, '');
@@ -148,8 +170,12 @@ def contact_card(admin_name=None, contact_methods=None):
148170
href = decoded;
149171
display = label;
150172
}}
151-
elem.innerHTML = '<strong>' + label + '</strong>' + badge + ': '
152-
+ '<a href="' + href + '" rel="nofollow noopener noreferrer">' + display + '</a>';
173+
if (!/^(mailto:|tel:|sms:|https?:\/\/)/.test(href)) {{
174+
elem.innerHTML = '<strong>' + esc(label) + '</strong>' + badge + ': ' + esc(display);
175+
return;
176+
}}
177+
elem.innerHTML = '<strong>' + esc(label) + '</strong>' + badge + ': '
178+
+ '<a href="' + esc(href) + '" rel="nofollow noopener noreferrer">' + esc(display) + '</a>';
153179
}} catch (e) {{
154180
// Decoding failed — noscript fallback remains
155181
}}
@@ -158,7 +184,7 @@ def contact_card(admin_name=None, contact_methods=None):
158184
}})();
159185
</script>"""
160186

161-
return Markup(html)
187+
return Markup(html_out)
162188

163189

164190
def _build_href(method_type, value):
@@ -174,8 +200,15 @@ def _build_href(method_type, value):
174200
if method_type == "whatsapp":
175201
digits = re.sub(r"[^\d+]", "", value)
176202
return f"https://wa.me/{digits}"
177-
if method_type in ("signal_username", "discord"):
203+
if method_type == "signal_url":
204+
if not any(value.startswith(s) for s in ("https://", "http://")):
205+
return ""
178206
return value
207+
if method_type in _NO_LINK_TYPES:
208+
return ""
209+
# Generic link — validate scheme
210+
if not any(value.startswith(s) for s in ("https://", "http://")):
211+
return ""
179212
return value
180213

181214

0 commit comments

Comments
 (0)