harden: restrict markdown link schemes; parse untrusted XML with defusedxml

Two small defensive-hardening changes:

- web/src/components/Markdown.tsx: render links only for http(s)/mailto
  schemes; other schemes (javascript:, data:, vbscript:) are dropped to
  plain text so a crafted link in rendered content can't execute on click.

- gateway/platforms/wecom_callback.py: parse the untrusted, pre-auth WeCom
  callback request body with defusedxml instead of xml.etree, blocking
  entity-expansion / billion-laughs (and XXE) on the parse path. defusedxml
  is already a dependency (uv.lock); response-building XML in
  wecom_crypto.py is unchanged (it is not parsed from untrusted input).

Verified: dashboard typechecks and builds; defusedxml blocks an
entity-expansion payload while valid WeCom envelopes still parse.
This commit is contained in:
TheOnlyMika 2026-05-26 00:23:19 +08:00 committed by Teknium
parent f4953bc648
commit 5744b17579
2 changed files with 21 additions and 3 deletions

View file

@ -17,7 +17,11 @@ import logging
import socket as _socket
import time
from typing import Any, Dict, List, Optional
from xml.etree import ElementTree as ET
# Security: parse untrusted, pre-auth request bodies (WeCom callbacks) with
# defusedxml to block billion-laughs / entity-expansion (and XXE) DoS. The
# parsing API (fromstring) is a drop-in for the stdlib calls used below;
# response-building XML lives in wecom_crypto.py and is not parsed here.
import defusedxml.ElementTree as ET
try:
from aiohttp import web

View file

@ -324,11 +324,24 @@ function InlineContent({
<HighlightedText text={node.content} terms={highlightTerms} />
</em>
);
case "link":
case "link": {
// Security: only render http(s)/mailto links. Other schemes
// (javascript:, data:, vbscript:) are dropped to plain text so a
// crafted link in agent/message content can't execute on click.
const href = node.href.trim();
if (!/^(https?:|mailto:)/i.test(href)) {
return (
<HighlightedText
key={i}
text={node.text}
terms={highlightTerms}
/>
);
}
return (
<a
key={i}
href={node.href}
href={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
@ -336,6 +349,7 @@ function InlineContent({
{node.text}
</a>
);
}
case "br":
return <br key={i} />;
}