2026-09-23 22:05:00 +08:00
|
|
|
"""
|
2026-10-26 00:50:43 +08:00
|
|
|
Copyright 2024-2026 ReplyPier
|
2026-09-23 22:05:00 +08:00
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
|
limitations under the License.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
import html
|
|
|
|
|
import urllib.parse
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decode_entities(text: str) -> str:
|
|
|
|
|
"""Decode HTML entities and URL encoding to catch obfuscated attacks"""
|
|
|
|
|
# Decode HTML entities multiple times to catch nested encoding
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
text = html.unescape(text)
|
|
|
|
|
|
|
|
|
|
# Decode URL encoding multiple times
|
|
|
|
|
for _ in range(3):
|
|
|
|
|
try:
|
|
|
|
|
text = urllib.parse.unquote(text)
|
|
|
|
|
except Exception:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sanitize_message(message: Optional[str]) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
Comprehensive sanitization to prevent XSS attacks including encoded payloads.
|
|
|
|
|
|
|
|
|
|
Blocks:
|
|
|
|
|
- All dangerous HTML tags (script, iframe, object, embed, svg, etc.)
|
|
|
|
|
- JavaScript URIs (including encoded variants)
|
|
|
|
|
- Data URIs (except data:image)
|
|
|
|
|
- Event handlers (all on* attributes)
|
|
|
|
|
- SVG-based XSS vectors
|
|
|
|
|
- Encoded payloads (HTML entities, URL encoding, Unicode)
|
|
|
|
|
- CSS expressions and behaviors
|
|
|
|
|
- Base64 encoded attacks
|
|
|
|
|
- Template literals and ES6 syntax
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: The message text to sanitize
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Sanitized message text or None if input is None
|
|
|
|
|
"""
|
|
|
|
|
if not message:
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
# SECURITY LAYER 1: Remove markdown link and image syntax to prevent injection
|
|
|
|
|
# This must happen BEFORE marked.js processes it on the frontend
|
|
|
|
|
# Pattern matches: [text](url) including nested brackets
|
|
|
|
|
message = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', message)
|
|
|
|
|
|
|
|
|
|
# SECURITY: Remove markdown image syntax  - capture alt text without the !
|
|
|
|
|
message = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'\1', message)
|
|
|
|
|
|
|
|
|
|
# SECURITY: Remove reference-style markdown links [text][ref]
|
|
|
|
|
message = re.sub(r'\[([^\]]+)\]\[[^\]]+\]', r'\1', message)
|
|
|
|
|
|
|
|
|
|
# SECURITY: Remove link reference definitions [ref]: url
|
|
|
|
|
message = re.sub(r'^\[[^\]]+\]:\s*.+$', '', message, flags=re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
# SECURITY LAYER 2: Decode any encoded content to catch obfuscated attacks
|
|
|
|
|
decoded_message = decode_entities(message)
|
|
|
|
|
|
|
|
|
|
# Check for encoded javascript: or data: URIs in decoded content
|
|
|
|
|
dangerous_patterns = [
|
|
|
|
|
r'javascript\s*:',
|
|
|
|
|
r'data\s*:(?!image/)',
|
|
|
|
|
r'vbscript\s*:',
|
|
|
|
|
r'file\s*:',
|
|
|
|
|
r'about\s*:',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for pattern in dangerous_patterns:
|
|
|
|
|
if re.search(pattern, decoded_message, re.IGNORECASE):
|
|
|
|
|
# If found in decoded content, sanitize the original too
|
|
|
|
|
message = re.sub(pattern, '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove all dangerous HTML tags (comprehensive list)
|
|
|
|
|
# SECURITY: Including <a> and <img> to prevent any external resource loading or clickable links
|
|
|
|
|
dangerous_tags = [
|
|
|
|
|
'script', 'iframe', 'frame', 'frameset', 'object', 'embed',
|
|
|
|
|
'applet', 'base', 'link', 'meta', 'style', 'svg', 'math',
|
|
|
|
|
'form', 'input', 'button', 'textarea', 'select', 'option',
|
|
|
|
|
'xml', 'xss', 'import', 'video', 'audio', 'track', 'source',
|
|
|
|
|
'canvas', 'details', 'template', 'slot', 'noscript',
|
|
|
|
|
'marquee', 'bgsound', 'keygen', 'command',
|
|
|
|
|
'a', 'img', 'area', 'map' # Block links and images to prevent external resource loading
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for tag in dangerous_tags:
|
|
|
|
|
# Remove opening and closing tags with any attributes
|
|
|
|
|
message = re.sub(rf'<{tag}[^>]*>.*?</{tag}>', '', message, flags=re.IGNORECASE | re.DOTALL)
|
|
|
|
|
message = re.sub(rf'<{tag}[^>]*/?>', '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove javascript: URIs (including encoded variants)
|
|
|
|
|
message = re.sub(r'j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:', '', message, flags=re.IGNORECASE)
|
|
|
|
|
message = re.sub(r'&#[x]?[0-9a-f]+;?', lambda m: decode_entities(m.group()), message)
|
|
|
|
|
|
|
|
|
|
# Remove vbscript: URIs
|
|
|
|
|
message = re.sub(r'vbscript:', '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove data: URIs that could contain HTML/JS (but preserve data:image)
|
|
|
|
|
message = re.sub(r'data:(?!image/)[^,;]*[,;]', '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove expression() and behavior CSS properties
|
|
|
|
|
message = re.sub(r'expression\s*\([^)]*\)', '', message, flags=re.IGNORECASE)
|
|
|
|
|
message = re.sub(r'behavior\s*:[^;]*', '', message, flags=re.IGNORECASE)
|
|
|
|
|
message = re.sub(r'-moz-binding\s*:', '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove import statements
|
|
|
|
|
message = re.sub(r'@import', '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove all event handlers (comprehensive list)
|
|
|
|
|
event_handlers = [
|
|
|
|
|
'onabort', 'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate',
|
|
|
|
|
'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus',
|
|
|
|
|
'onbeforepaste', 'onbeforeprint', 'onbeforeunload', 'onbeforeupdate', 'onblur',
|
|
|
|
|
'onbounce', 'oncellchange', 'onchange', 'onclick', 'oncontextmenu', 'oncontrolselect',
|
|
|
|
|
'oncopy', 'oncut', 'ondataavailable', 'ondatasetchanged', 'ondatasetcomplete',
|
|
|
|
|
'ondblclick', 'ondeactivate', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
|
|
|
|
|
'ondragover', 'ondragstart', 'ondrop', 'onerror', 'onerrorupdate', 'onfilterchange',
|
|
|
|
|
'onfinish', 'onfocus', 'onfocusin', 'onfocusout', 'onhelp', 'oninput', 'oninvalid',
|
|
|
|
|
'onkeydown', 'onkeypress', 'onkeyup', 'onlayoutcomplete', 'onload', 'onlosecapture',
|
|
|
|
|
'onmessage', 'onmousedown', 'onmouseenter', 'onmouseleave', 'onmousemove',
|
|
|
|
|
'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onmove', 'onmoveend',
|
|
|
|
|
'onmovestart', 'onpaste', 'onpropertychange', 'onreadystatechange', 'onreset',
|
|
|
|
|
'onresize', 'onresizeend', 'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete',
|
|
|
|
|
'onrowsinserted', 'onscroll', 'onsearch', 'onselect', 'onselectionchange',
|
|
|
|
|
'onselectstart', 'onstart', 'onstop', 'onsubmit', 'onunload',
|
|
|
|
|
'oncanplay', 'oncanplaythrough', 'oncuechange', 'ondurationchange', 'onemptied',
|
|
|
|
|
'onended', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onpause', 'onplay',
|
|
|
|
|
'onplaying', 'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled',
|
|
|
|
|
'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'ontoggle',
|
|
|
|
|
'onauxclick', 'ongotpointercapture', 'onlostpointercapture', 'onpointercancel',
|
|
|
|
|
'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove', 'onpointerout',
|
|
|
|
|
'onpointerover', 'onpointerup', 'onwheel', 'onanimationcancel', 'onanimationend',
|
|
|
|
|
'onanimationiteration', 'onanimationstart', 'ontransitioncancel', 'ontransitionend',
|
|
|
|
|
'ontransitionrun', 'ontransitionstart', 'onshow', 'onsort', 'onpointerrawupdate'
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for handler in event_handlers:
|
|
|
|
|
# Remove with quotes
|
|
|
|
|
message = re.sub(rf'{handler}\s*=\s*["\'][^"\']*["\']', '', message, flags=re.IGNORECASE)
|
|
|
|
|
# Remove without quotes
|
|
|
|
|
message = re.sub(rf'{handler}\s*=\s*[^>\s]*', '', message, flags=re.IGNORECASE)
|
|
|
|
|
# Remove with backticks (template literals)
|
|
|
|
|
message = re.sub(rf'{handler}\s*=\s*`[^`]*`', '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove dangerous attributes
|
|
|
|
|
# SECURITY: Including src, href, and all attributes that can load external resources
|
|
|
|
|
dangerous_attrs = [
|
|
|
|
|
'formaction', 'action', 'form', 'srcdoc', 'srcset', 'dynsrc', 'lowsrc',
|
|
|
|
|
'ping', 'poster', 'background', 'code', 'codebase', 'archive', 'profile',
|
|
|
|
|
'xmlns', 'xlink:href', 'attributename', 'from', 'to', 'values', 'begin',
|
|
|
|
|
'autofocus', 'autoplay', 'controls', 'manifest', 'sandbox',
|
|
|
|
|
'src', 'href', 'data', 'icon', 'usemap' # Block all resource-loading attributes
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for attr in dangerous_attrs:
|
|
|
|
|
message = re.sub(rf'{attr}\s*=\s*["\'][^"\']*["\']', '', message, flags=re.IGNORECASE)
|
|
|
|
|
message = re.sub(rf'{attr}\s*=\s*[^>\s]*', '', message, flags=re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove HTML comments (can be used to hide malicious code)
|
|
|
|
|
message = re.sub(r'<!--.*?-->', '', message, flags=re.DOTALL)
|
|
|
|
|
|
|
|
|
|
# Remove CDATA sections
|
|
|
|
|
message = re.sub(r'<!\[CDATA\[.*?\]\]>', '', message, flags=re.DOTALL | re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
# Remove XML processing instructions
|
|
|
|
|
message = re.sub(r'<\?.*?\?>', '', message, flags=re.DOTALL)
|
|
|
|
|
|
|
|
|
|
# Remove null bytes and other control characters that can bypass filters
|
|
|
|
|
message = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', message)
|
|
|
|
|
|
|
|
|
|
# Remove Unicode directional override characters (can hide malicious code)
|
|
|
|
|
message = re.sub(r'[\u202a-\u202e\u2066-\u2069]', '', message)
|
|
|
|
|
|
|
|
|
|
return message
|