Harden security
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user