Files
replypier/backend/app/utils/sanitize.py
T
WangShen007 54b949b392 chore(brand): rename the product to ReplyPier
The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.

- widget loader is now webclient/replypier.min.js; both bundles rebuilt
  from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
  rewritten rather than string-swapped; the demo config no longer ships
  the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
  AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
  documents carrying the old local data over
2026-10-26 00:51:03 +08:00

195 lines
8.9 KiB
Python

"""
Copyright 2024-2026 ReplyPier
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 ![alt](url) - 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