Skip to content

Commit 3a1660e

Browse files
🔒 security: Add XSS and URL injection protection
1 parent fa8e241 commit 3a1660e

5 files changed

Lines changed: 195 additions & 41 deletions

File tree

api/v1/alert.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from utils.helpers import fetch_location_by_ip, get_preferred_ip_address
3030
from utils.token import confirm_token, generate_confirmation_token
3131
from utils.validation import validate_email_with_tld, validate_url, validate_variables
32+
from utils.safe_encoding import build_safe_url
3233

3334
router = APIRouter()
3435
templates = Jinja2Templates(directory="templates")
@@ -192,11 +193,11 @@ async def alert_me_verification(verification_token: str, request: Request):
192193
if not has_exposure and not has_sensitive_exposure:
193194
return templates.TemplateResponse("email_verify.html", {"request": request})
194195

195-
# If exposures are found
196-
base_url = "https://xposedornot.com/"
197-
email_param = f"email={user_email}"
198-
token_param = f"&token={verification_token}"
199-
breaches_link = f"{base_url}data-breaches-risks.html?{email_param}{token_param}"
196+
# If exposures are found, generate link with properly encoded parameters
197+
breaches_link = build_safe_url(
198+
"https://xposedornot.com/data-breaches-risks.html",
199+
{"email": user_email, "token": verification_token},
200+
)
200201
return templates.TemplateResponse(
201202
"email_success.html",
202203
{"request": request, "breaches_link": breaches_link},

api/v1/analytics.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
validate_url,
5050
validate_variables,
5151
)
52+
from utils.safe_encoding import (
53+
build_safe_url,
54+
escape_html_attr,
55+
escape_url_fragment,
56+
)
5257

5358
router = APIRouter()
5459
templates = Jinja2Templates(directory="templates")
@@ -230,7 +235,8 @@ async def domain_verify(request: Request, verification_token: str) -> HTMLRespon
230235
)
231236

232237
user_email = await confirm_token(verification_token)
233-
if not user_email:
238+
# Re-validate email from token for defense-in-depth
239+
if not user_email or not validate_email_with_tld(user_email):
234240
return HTMLResponse(
235241
content=templates.TemplateResponse(
236242
"domain_dashboard_error.html", {"request": request}
@@ -254,11 +260,11 @@ async def domain_verify(request: Request, verification_token: str) -> HTMLRespon
254260
except Exception as e:
255261
raise
256262

257-
# Generate dashboard link
258-
base_url = "https://xposedornot.com/"
259-
email_param = f"email={user_email}"
260-
token_param = f"token={verification_token}"
261-
dashboard_link = f"{base_url}breach-dashboard.html?{email_param}&{token_param}"
263+
# Generate dashboard link with properly encoded parameters
264+
dashboard_link = build_safe_url(
265+
"https://xposedornot.com/breach-dashboard.html",
266+
{"email": user_email, "token": verification_token},
267+
)
262268

263269
return HTMLResponse(
264270
content=templates.TemplateResponse(
@@ -534,8 +540,8 @@ async def send_domain_breaches(
534540
for breach_name in breaches:
535541
breach_logo = all_breaches_logo.get(breach_name, "")
536542
details = (
537-
f"<img src='{breach_logo}' style='height:40px;width:65px;' />"
538-
f"<a target='_blank' href='https://xposedornot.com/xposed/#{breach_name}'>"
543+
f"<img src='{escape_html_attr(breach_logo)}' style='height:40px;width:65px;' />"
544+
f"<a target='_blank' href='https://xposedornot.com/xposed/#{escape_url_fragment(breach_name)}'>"
539545
" &nbsp;Details</a>"
540546
)
541547
breach_node = {
@@ -890,8 +896,8 @@ async def get_breach_hierarchy_analytics(
890896
logo = query.get("logo", "default_logo.jpg")
891897

892898
details = (
893-
f"<img src='{logo}' style='height:40px;width:65px;' />"
894-
f"<a target='_blank' href='https://xposedornot.com/xposed/#{bid}'>"
899+
f"<img src='{escape_html_attr(logo)}' style='height:40px;width:65px;' />"
900+
f"<a target='_blank' href='https://xposedornot.com/xposed/#{escape_url_fragment(bid)}'>"
895901
" &nbsp;Details</a>"
896902
)
897903

@@ -927,8 +933,8 @@ async def get_breach_hierarchy_analytics(
927933
logo = query.get("logo", "default_logo.jpg")
928934

929935
details = (
930-
f"<img src='{logo}' style='height:40px;width:65px;' />"
931-
f"<a target='_blank' href='https://xposedornot.com/xposed/#{bid}'>"
936+
f"<img src='{escape_html_attr(logo)}' style='height:40px;width:65px;' />"
937+
f"<a target='_blank' href='https://xposedornot.com/xposed/#{escape_url_fragment(bid)}'>"
932938
" &nbsp;Details</a>"
933939
)
934940

api/v1/feeds.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from models.base import BaseResponse
1010
from services.send_email import send_exception_email
1111
from utils.custom_limiter import custom_rate_limiter
12+
from utils.safe_encoding import escape_rss_content, escape_url_fragment
1213

1314
router = APIRouter()
1415

@@ -87,12 +88,14 @@ async def rss_feed(request: Request):
8788

8889
feed_entry.id(entity_key)
8990
feed_entry.title(entity_key)
90-
feed_entry.link(href="https://xposedornot.com/xposed#" + entity_key)
91+
feed_entry.link(
92+
href="https://xposedornot.com/xposed#" + escape_url_fragment(entity_key)
93+
)
9194

9295
description = (
93-
str(entity["xposure_desc"])
96+
escape_rss_content(entity["xposure_desc"])
9497
+ ". Exposed data: "
95-
+ str(entity["xposed_data"])
98+
+ escape_rss_content(entity["xposed_data"])
9699
)
97100
feed_entry.description(description=description)
98101
feed_entry.pubDate(entity["timestamp"])

api/v1/monthly_digest_helpers.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import time
66
from datetime import datetime, timedelta, timezone
77
from google.cloud import datastore
8+
from utils.safe_encoding import escape_html, build_safe_url
89

910
logger = logging.getLogger(__name__)
1011

@@ -259,14 +260,14 @@ def generate_mobile_exposure_cards(user_exposures: list) -> str:
259260
cards += f"""
260261
<div class="mobile-card">
261262
<div style="font-weight: bold; color: #e74c3c; font-size: 16px; margin-bottom: 8px;">
262-
🚨 {exposure.get('breach_name', 'Unknown')}
263+
🚨 {escape_html(exposure.get('breach_name', 'Unknown'))}
263264
</div>
264265
<div style="color: #495057; font-size: 14px; line-height: 1.4;">
265-
<div style="margin: 4px 0;"><strong>Breached:</strong> {exposure.get('breach_date', 'Unknown')}</div>
266-
<div style="margin: 4px 0;"><strong>Added:</strong> {exposure.get('added_date', 'Unknown')}</div>
266+
<div style="margin: 4px 0;"><strong>Breached:</strong> {escape_html(exposure.get('breach_date', 'Unknown'))}</div>
267+
<div style="margin: 4px 0;"><strong>Added:</strong> {escape_html(exposure.get('added_date', 'Unknown'))}</div>
267268
<div style="margin: 4px 0;"><strong>Records:</strong> {exposure.get('records_count', 0):,}</div>
268269
<div style="margin: 8px 0 0 0; padding: 8px; background-color: #f8f9fa; border-radius: 4px; font-size: 12px;">
269-
<strong>Data Exposed:</strong><br>{exposure.get('data_exposed', 'Unknown')}
270+
<strong>Data Exposed:</strong><br>{escape_html(exposure.get('data_exposed', 'Unknown'))}
270271
</div>
271272
</div>
272273
</div>"""
@@ -286,14 +287,14 @@ def generate_mobile_breach_cards(new_breaches: list) -> str:
286287
cards += f"""
287288
<div class="mobile-card">
288289
<div style="font-weight: bold; color: #f39c12; font-size: 16px; margin-bottom: 8px;">
289-
🚨 {breach.get('breach_name', 'Unknown')}
290+
🚨 {escape_html(breach.get('breach_name', 'Unknown'))}
290291
</div>
291292
<div style="color: #495057; font-size: 14px; line-height: 1.4;">
292-
<div style="margin: 4px 0;"><strong>Breached:</strong> {breach.get('breach_date', 'Unknown')}</div>
293-
<div style="margin: 4px 0;"><strong>Added:</strong> {breach.get('added_date', 'Unknown')}</div>
293+
<div style="margin: 4px 0;"><strong>Breached:</strong> {escape_html(breach.get('breach_date', 'Unknown'))}</div>
294+
<div style="margin: 4px 0;"><strong>Added:</strong> {escape_html(breach.get('added_date', 'Unknown'))}</div>
294295
<div style="margin: 4px 0;"><strong>Records:</strong> {breach.get('records_count', 0):,}</div>
295296
<div style="margin: 8px 0 0 0; padding: 8px; background-color: #f8f9fa; border-radius: 4px; font-size: 12px;">
296-
<strong>Data Exposed:</strong><br>{breach.get('data_exposed', 'Unknown')}
297+
<strong>Data Exposed:</strong><br>{escape_html(breach.get('data_exposed', 'Unknown'))}
297298
</div>
298299
</div>
299300
</div>"""
@@ -318,15 +319,22 @@ async def generate_html_template(
318319
# Use the verified domains passed to the function (not derived from exposures)
319320
# user_domains parameter already contains the verified domains for this user
320321

321-
# Summary info
322-
domains_text = ", ".join(user_domains) if user_domains else "No verified domains"
322+
# Summary info (escape domains for safe HTML display)
323+
domains_text = (
324+
", ".join(escape_html(d) for d in user_domains)
325+
if user_domains
326+
else "No verified domains"
327+
)
323328
summary_info = (
324329
"Your summary: <strong>{} verified domains</strong> ({}) • "
325330
"<strong>{} exposures</strong> • <strong>{} new breaches</strong> this month"
326331
).format(len(user_domains), domains_text, len(user_exposures), len(new_breaches))
327332

328-
# Dashboard URL
329-
dashboard_url = f"https://xposedornot.com/breach-dashboard?email={email}&token={dashboard_token}"
333+
# Dashboard URL with properly encoded parameters
334+
dashboard_url = build_safe_url(
335+
"https://xposedornot.com/breach-dashboard",
336+
{"email": email, "token": dashboard_token},
337+
)
330338

331339
# Build mobile-friendly cards instead of table rows
332340
exposure_cards = ""
@@ -335,14 +343,14 @@ async def generate_html_template(
335343
exposure_cards += f"""
336344
<div style="border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; background-color: #fff;">
337345
<div style="font-weight: bold; color: #e74c3c; font-size: 16px; margin-bottom: 8px;">
338-
🚨 {exposure.get('breach_name', 'Unknown')}
346+
🚨 {escape_html(exposure.get('breach_name', 'Unknown'))}
339347
</div>
340348
<div style="color: #495057; font-size: 14px; line-height: 1.4;">
341-
<div style="margin: 4px 0;"><strong>Breached:</strong> {exposure.get('breach_date', 'Unknown')}</div>
342-
<div style="margin: 4px 0;"><strong>Added:</strong> {exposure.get('added_date', 'Unknown')}</div>
349+
<div style="margin: 4px 0;"><strong>Breached:</strong> {escape_html(exposure.get('breach_date', 'Unknown'))}</div>
350+
<div style="margin: 4px 0;"><strong>Added:</strong> {escape_html(exposure.get('added_date', 'Unknown'))}</div>
343351
<div style="margin: 4px 0;"><strong>Records:</strong> {exposure.get('records_count', 0):,}</div>
344352
<div style="margin: 8px 0 0 0; padding: 8px; background-color: #f8f9fa; border-radius: 4px; font-size: 12px;">
345-
<strong>Data Exposed:</strong><br>{exposure.get('data_exposed', 'Unknown')}
353+
<strong>Data Exposed:</strong><br>{escape_html(exposure.get('data_exposed', 'Unknown'))}
346354
</div>
347355
</div>
348356
</div>"""
@@ -360,14 +368,14 @@ async def generate_html_template(
360368
breach_cards += f"""
361369
<div style="border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; background-color: #fff;">
362370
<div style="font-weight: bold; color: #f39c12; font-size: 16px; margin-bottom: 8px;">
363-
🚨 {breach.get('breach_name', 'Unknown')}
371+
🚨 {escape_html(breach.get('breach_name', 'Unknown'))}
364372
</div>
365373
<div style="color: #495057; font-size: 14px; line-height: 1.4;">
366-
<div style="margin: 4px 0;"><strong>Breached:</strong> {breach.get('breach_date', 'Unknown')}</div>
367-
<div style="margin: 4px 0;"><strong>Added:</strong> {breach.get('added_date', 'Unknown')}</div>
374+
<div style="margin: 4px 0;"><strong>Breached:</strong> {escape_html(breach.get('breach_date', 'Unknown'))}</div>
375+
<div style="margin: 4px 0;"><strong>Added:</strong> {escape_html(breach.get('added_date', 'Unknown'))}</div>
368376
<div style="margin: 4px 0;"><strong>Records:</strong> {breach.get('records_count', 0):,}</div>
369377
<div style="margin: 8px 0 0 0; padding: 8px; background-color: #f8f9fa; border-radius: 4px; font-size: 12px;">
370-
<strong>Data Exposed:</strong><br>{breach.get('data_exposed', 'Unknown')}
378+
<strong>Data Exposed:</strong><br>{escape_html(breach.get('data_exposed', 'Unknown'))}
371379
</div>
372380
</div>
373381
</div>"""
@@ -448,7 +456,7 @@ async def generate_html_template(
448456
449457
<!-- Email Footer -->
450458
<div style="background-color: #f8f9fa; padding: 20px; margin-top: 30px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d; text-align: center;">
451-
<p style="margin: 0 0 10px 0;">This email was sent to <strong>{email}</strong> because of monthly breach notifications in XposedOrNot.com.</p>
459+
<p style="margin: 0 0 10px 0;">This email was sent to <strong>{escape_html(email)}</strong> because of monthly breach notifications in XposedOrNot.com.</p>
452460
<p style="margin: 0;">© 2025 XposedOrNot. All rights reserved. | <a href="https://xposedornot.com/dashboard" style="color: #6c757d;">Visit Website</a></p>
453461
</div>
454462

utils/safe_encoding.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""
2+
Safe encoding utilities for preventing XSS and URL injection vulnerabilities.
3+
4+
This module provides standardized functions for:
5+
- HTML escaping (prevent XSS in displayed content)
6+
- URL encoding (prevent parameter injection in URLs)
7+
- RSS content escaping (prevent XSS in feed readers)
8+
9+
Usage:
10+
from utils.safe_encoding import escape_html, build_safe_url, escape_html_attr
11+
12+
# For displaying user data in HTML text:
13+
f"Hello, {escape_html(username)}"
14+
15+
# For building URLs with parameters:
16+
url = build_safe_url("https://example.com/page.html", {"email": email, "token": token})
17+
18+
# For HTML attributes (src, href with user data):
19+
f"<img src='{escape_html_attr(logo_url)}' />"
20+
21+
# For fragment identifiers:
22+
f"<a href='https://example.com/#{escape_url_fragment(breach_name)}'>"
23+
"""
24+
25+
import html
26+
from urllib.parse import urlencode, quote
27+
from typing import Optional, Dict, Any
28+
29+
30+
def escape_html(value: Any) -> str:
31+
"""
32+
Escape HTML special characters for safe display in HTML content.
33+
34+
Converts: < > & " ' to their HTML entity equivalents.
35+
Use for: Text content displayed to users in HTML.
36+
37+
Args:
38+
value: The value to escape (will be converted to string)
39+
40+
Returns:
41+
HTML-escaped string safe for display
42+
43+
Example:
44+
escape_html("<script>alert('xss')</script>")
45+
→ "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
46+
"""
47+
if value is None:
48+
return ""
49+
return html.escape(str(value), quote=True)
50+
51+
52+
def escape_html_attr(value: Any) -> str:
53+
"""
54+
Escape value for use in HTML attributes (src, href, etc.).
55+
56+
Use for: Dynamic values in img src, anchor href, style attributes, etc.
57+
58+
Args:
59+
value: The value to escape (will be converted to string)
60+
61+
Returns:
62+
HTML-escaped string safe for use in attributes
63+
64+
Example:
65+
f"<img src='{escape_html_attr(logo_url)}' />"
66+
"""
67+
if value is None:
68+
return ""
69+
return html.escape(str(value), quote=True)
70+
71+
72+
def escape_url_fragment(value: Any) -> str:
73+
"""
74+
Escape value for use in URL fragment identifiers (#anchor).
75+
76+
Use for: Anchor links with dynamic values like breach names.
77+
78+
Args:
79+
value: The value to escape (will be converted to string)
80+
81+
Returns:
82+
URL-encoded string safe for use in fragments
83+
84+
Example:
85+
f"<a href='https://example.com/page#{escape_url_fragment(section_name)}'>"
86+
"""
87+
if value is None:
88+
return ""
89+
# Quote everything except alphanumerics and safe chars
90+
return quote(str(value), safe="")
91+
92+
93+
def build_safe_url(base_url: str, params: Optional[Dict[str, Any]] = None) -> str:
94+
"""
95+
Build a URL with properly encoded query parameters.
96+
97+
Use for: Any URL construction with dynamic query parameters.
98+
99+
Args:
100+
base_url: The base URL (e.g., "https://example.com/page.html")
101+
params: Dictionary of query parameters to encode
102+
103+
Returns:
104+
Complete URL with properly encoded query string
105+
106+
Example:
107+
build_safe_url("https://example.com/dashboard", {"email": "user@test.com"})
108+
→ "https://example.com/dashboard?email=user%40test.com"
109+
"""
110+
if not params:
111+
return base_url
112+
113+
query_string = urlencode(params)
114+
separator = "&" if "?" in base_url else "?"
115+
return f"{base_url}{separator}{query_string}"
116+
117+
118+
def escape_rss_content(value: Any) -> str:
119+
"""
120+
Escape content for RSS feed descriptions.
121+
122+
RSS readers may render HTML, so we escape to prevent XSS.
123+
Uses quote=False to avoid escaping quotes in text content.
124+
125+
Args:
126+
value: The value to escape (will be converted to string)
127+
128+
Returns:
129+
XML-escaped string safe for RSS content
130+
131+
Example:
132+
escape_rss_content(breach_description)
133+
"""
134+
if value is None:
135+
return ""
136+
return html.escape(str(value), quote=False)

0 commit comments

Comments
 (0)