Harden security

This commit is contained in:
2026-03-05 05:42:19 +00:00
parent 06735bdfb1
commit 438e561da0
75 changed files with 6260 additions and 278 deletions

View File

@@ -16,6 +16,22 @@ SIGNAL_UUID_PATTERN = re.compile(
)
def _safe_parse_send_response(payload_value) -> int | bool:
payload = payload_value
if isinstance(payload_value, str):
try:
payload = orjson.loads(payload_value)
except orjson.JSONDecodeError:
return False
if not isinstance(payload, dict):
return False
try:
ts = payload.get("timestamp")
return int(ts) if ts else False
except (TypeError, ValueError):
return False
def normalize_signal_recipient(recipient: str) -> str:
raw = str(recipient or "").strip()
if not raw:
@@ -395,8 +411,8 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=None):
response.status_code == status.HTTP_201_CREATED
): # Signal server returns 201 on success
try:
ts = orjson.loads(response.text).get("timestamp", None)
return ts if ts else False
except orjson.JSONDecodeError:
return False
payload = response.json()
except ValueError:
payload = {}
return _safe_parse_send_response(payload)
return False # If response status is not 201

View File

@@ -17,6 +17,10 @@ from django.core.cache import cache
from core.clients import signalapi
from core.messaging import media_bridge
from core.security.attachments import (
validate_attachment_metadata,
validate_attachment_url,
)
from core.transports.capabilities import supports, unsupported_reason
from core.util import logs
@@ -665,17 +669,21 @@ async def _normalize_gateway_attachment(service: str, row: dict, session):
if isinstance(content, memoryview):
content = content.tobytes()
if isinstance(content, bytes):
filename, content_type = validate_attachment_metadata(
filename=normalized.get("filename") or "attachment.bin",
content_type=normalized.get("content_type") or "application/octet-stream",
size=normalized.get("size") or len(content),
)
blob_key = media_bridge.put_blob(
service=service,
content=content,
filename=normalized.get("filename") or "attachment.bin",
content_type=normalized.get("content_type") or "application/octet-stream",
filename=filename,
content_type=content_type,
)
return {
"blob_key": blob_key,
"filename": normalized.get("filename") or "attachment.bin",
"content_type": normalized.get("content_type")
or "application/octet-stream",
"filename": filename,
"content_type": content_type,
"size": normalized.get("size") or len(content),
}
@@ -685,33 +693,39 @@ async def _normalize_gateway_attachment(service: str, row: dict, session):
source_url = normalized.get("url")
if source_url:
try:
async with session.get(source_url) as response:
safe_url = validate_attachment_url(source_url)
async with session.get(safe_url) as response:
if response.status == 200:
payload = await response.read()
blob_key = media_bridge.put_blob(
service=service,
content=payload,
filename, content_type = validate_attachment_metadata(
filename=normalized.get("filename")
or source_url.rstrip("/").split("/")[-1]
or safe_url.rstrip("/").split("/")[-1]
or "attachment.bin",
content_type=normalized.get("content_type")
or response.headers.get(
"Content-Type", "application/octet-stream"
),
size=normalized.get("size") or len(payload),
)
blob_key = media_bridge.put_blob(
service=service,
content=payload,
filename=filename,
content_type=content_type,
)
return {
"blob_key": blob_key,
"filename": normalized.get("filename")
or source_url.rstrip("/").split("/")[-1]
or "attachment.bin",
"content_type": normalized.get("content_type")
or response.headers.get(
"Content-Type", "application/octet-stream"
),
"filename": filename,
"content_type": content_type,
"size": normalized.get("size") or len(payload),
}
except Exception:
log.warning("%s attachment fetch failed for %s", service, source_url)
except Exception as exc:
log.warning(
"%s attachment fetch failed for %s: %s",
service,
source_url,
exc,
)
return normalized
@@ -1074,21 +1088,27 @@ async def fetch_attachment(service: str, attachment_ref: dict):
if blob_key:
return media_bridge.get_blob(blob_key)
if direct_url:
safe_url = validate_attachment_url(direct_url)
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(direct_url) as response:
async with session.get(safe_url) as response:
if response.status != 200:
return None
content = await response.read()
return {
"content": content,
"content_type": response.headers.get(
filename, content_type = validate_attachment_metadata(
filename=attachment_ref.get("filename")
or safe_url.rstrip("/").split("/")[-1]
or "attachment.bin",
content_type=response.headers.get(
"Content-Type",
attachment_ref.get("content_type", "application/octet-stream"),
),
"filename": attachment_ref.get("filename")
or direct_url.rstrip("/").split("/")[-1]
or "attachment.bin",
size=len(content),
)
return {
"content": content,
"content_type": content_type,
"filename": filename,
"size": len(content),
}
return None

View File

@@ -17,6 +17,10 @@ from django.core.cache import cache
from core.clients import ClientBase, transport
from core.messaging import history, media_bridge, reply_sync
from core.models import Message, PersonIdentifier, PlatformChatLink
from core.security.attachments import (
validate_attachment_metadata,
validate_attachment_url,
)
try:
from google.protobuf.json_format import MessageToDict
@@ -3141,31 +3145,42 @@ class WhatsAppClient(ClientBase):
if isinstance(content, memoryview):
content = content.tobytes()
if isinstance(content, bytes):
filename, content_type = validate_attachment_metadata(
filename=(attachment or {}).get("filename") or "attachment.bin",
content_type=(attachment or {}).get("content_type")
or "application/octet-stream",
size=len(content),
)
return {
"content": content,
"filename": (attachment or {}).get("filename") or "attachment.bin",
"content_type": (attachment or {}).get("content_type")
or "application/octet-stream",
"filename": filename,
"content_type": content_type,
"size": len(content),
}
url = (attachment or {}).get("url")
if url:
safe_url = validate_attachment_url(url)
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
async with session.get(safe_url) as response:
if response.status != 200:
return None
payload = await response.read()
return {
"content": payload,
"filename": (attachment or {}).get("filename")
or url.rstrip("/").split("/")[-1]
filename, content_type = validate_attachment_metadata(
filename=(attachment or {}).get("filename")
or safe_url.rstrip("/").split("/")[-1]
or "attachment.bin",
"content_type": (attachment or {}).get("content_type")
content_type=(attachment or {}).get("content_type")
or response.headers.get(
"Content-Type", "application/octet-stream"
),
size=len(payload),
)
return {
"content": payload,
"filename": filename,
"content_type": content_type,
"size": len(payload),
}
return None
@@ -3320,11 +3335,19 @@ class WhatsAppClient(ClientBase):
payload = await self._fetch_attachment_payload(attachment)
if not payload:
continue
mime = str(
payload.get("content_type") or "application/octet-stream"
).lower()
data = payload.get("content") or b""
filename = payload.get("filename") or "attachment.bin"
try:
filename, mime = validate_attachment_metadata(
filename=payload.get("filename") or "attachment.bin",
content_type=payload.get("content_type")
or "application/octet-stream",
size=payload.get("size")
or (len(data) if isinstance(data, (bytes, bytearray)) else 0),
)
except Exception as exc:
self.log.warning("whatsapp blocked attachment: %s", exc)
continue
mime = str(mime).lower()
attachment_target = jid_obj if jid_obj is not None else jid
send_method = "document"
if mime.startswith("image/") and hasattr(self._client, "send_image"):
@@ -3372,7 +3395,7 @@ class WhatsAppClient(ClientBase):
sent_ts,
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
)
await _record_bridge(response, sent_ts, body_hint=filename)
await _record_bridge(response, sent_ts, body_hint="attachment")
sent_any = True
if getattr(settings, "WHATSAPP_DEBUG", False):
self.log.debug(

View File

@@ -30,6 +30,10 @@ from core.models import (
User,
WorkspaceConversation,
)
from core.security.attachments import (
validate_attachment_metadata,
validate_attachment_url,
)
from core.util import logs
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
@@ -49,9 +53,8 @@ def _filename_from_url(url_value):
def _content_type_from_filename_or_url(url_value, default="application/octet-stream"):
filename = _filename_from_url(url_value)
guessed, _ = mimetypes.guess_type(filename)
return guessed or default
_ = url_value
return str(default or "application/octet-stream")
def _extract_xml_attachment_urls(message_stanza):
@@ -1013,13 +1016,21 @@ class XMPPComponent(ComponentXMPP):
url_value = _clean_url(att.attrib.get("url"))
if not url_value:
continue
try:
safe_url = validate_attachment_url(url_value)
filename, content_type = validate_attachment_metadata(
filename=att.attrib.get("filename") or _filename_from_url(safe_url),
content_type=att.attrib.get("content_type")
or "application/octet-stream",
)
except Exception as exc:
self.log.warning("xmpp dropped unsafe attachment url=%s: %s", url_value, exc)
continue
attachments.append(
{
"url": url_value,
"filename": att.attrib.get("filename")
or _filename_from_url(url_value),
"content_type": att.attrib.get("content_type")
or "application/octet-stream",
"url": safe_url,
"filename": filename,
"content_type": content_type,
}
)
@@ -1028,11 +1039,19 @@ class XMPPComponent(ComponentXMPP):
url_value = _clean_url(oob.text)
if not url_value:
continue
guessed_content_type = _content_type_from_filename_or_url(url_value)
try:
safe_url = validate_attachment_url(url_value)
filename, guessed_content_type = validate_attachment_metadata(
filename=_filename_from_url(safe_url),
content_type=_content_type_from_filename_or_url(safe_url),
)
except Exception as exc:
self.log.warning("xmpp dropped unsafe oob url=%s: %s", url_value, exc)
continue
attachments.append(
{
"url": url_value,
"filename": _filename_from_url(url_value),
"url": safe_url,
"filename": filename,
"content_type": guessed_content_type,
}
)
@@ -1043,11 +1062,19 @@ class XMPPComponent(ComponentXMPP):
for url_value in extracted_urls:
if url_value in existing_urls:
continue
guessed_content_type = _content_type_from_filename_or_url(url_value)
try:
safe_url = validate_attachment_url(url_value)
filename, guessed_content_type = validate_attachment_metadata(
filename=_filename_from_url(safe_url),
content_type=_content_type_from_filename_or_url(safe_url),
)
except Exception as exc:
self.log.warning("xmpp dropped extracted unsafe url=%s: %s", url_value, exc)
continue
attachments.append(
{
"url": url_value,
"filename": _filename_from_url(url_value),
"url": safe_url,
"filename": filename,
"content_type": guessed_content_type,
}
)
@@ -1397,7 +1424,16 @@ class XMPPComponent(ComponentXMPP):
async def upload_and_send(self, att, upload_slot, recipient_jid, sender_jid):
"""Uploads a file and immediately sends the corresponding XMPP message."""
upload_url, put_url, auth_header = upload_slot
headers = {"Content-Type": att["content_type"]}
try:
filename, content_type = validate_attachment_metadata(
filename=att.get("filename"),
content_type=att.get("content_type"),
size=att.get("size"),
)
except Exception as exc:
self.log.warning("xmpp blocked outbound attachment: %s", exc)
return None
headers = {"Content-Type": content_type}
if auth_header:
headers["Authorization"] = auth_header
@@ -1412,7 +1448,7 @@ class XMPPComponent(ComponentXMPP):
)
return None
self.log.debug(
"Successfully uploaded %s to %s", att["filename"], upload_url
"Successfully uploaded %s to %s", filename, upload_url
)
# Send XMPP message immediately after successful upload