sanitize links, include mentioned members

This commit is contained in:
Evgeny @ SimpleX Chat committed 2026-06-04 19:43:30 +00:00
1 parent 53cea75afd
commit 7a3b83a660
3 files changed
+27 -24

No files matched your search

+9 -15
View File
@@ -14,7 +14,6 @@ module Simplex.Chat.Web
WebMemberProfile (..),
WebFileInfo (..),
webPreviewWorker,
renderWebPreviews,
writeCorsConfig,
removeStaleFiles,
channelContentChanged,
@@ -60,7 +59,7 @@ import Simplex.Chat.Messages
)
import Simplex.Chat.Messages.CIContent (ciMsgContent)
import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport)
import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups, getRelayServedGroups)
import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups)
import Simplex.Chat.Store.Messages (getGroupWebPreviewItems)
import Simplex.Chat.Store.Shared (getGroupInfo)
import Simplex.Chat.Types
@@ -230,14 +229,6 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva
Right a -> pure (Just a)
Left _ -> go us
renderWebPreviews :: WebPreviewConfig -> ChatController -> User -> IO ()
renderWebPreviews cfg@WebPreviewConfig {webJsonDir} cc user = do
let vr' = chatVRange (config cc)
createDirectoryIfMissing True webJsonDir
groups <- withTransaction (chatStore cc) $ \db -> getRelayServedGroups db vr' user
forM_ [gInfo | gInfo <- groups, hasPublicGroup gInfo] $ \gInfo ->
void $ renderGroupPreview cfg cc user gInfo
renderGroupPreview :: WebPreviewConfig -> ChatController -> User -> GroupInfo -> IO (Maybe (Text, CorsOrigin))
renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gInfo@GroupInfo {groupProfile = gp@GroupProfile {shortDescr = sd, description = wd, publicGroup}} =
case publicGroup of
@@ -250,7 +241,7 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI
ts <- getCurrentTime
let rendered = mapMaybe toRenderedItem $ rights items
msgs = map fst rendered
senders = collectSenders $ map memberToProfile owners <> mapMaybe snd rendered
senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered
preview = WebChannelPreview
{ channel = gp,
shortDescription = toFormattedText =<< sd,
@@ -309,14 +300,17 @@ channelRemoved cc gId =
writeTVar corsNeeded True
void $ tryPutTMVar wakeSignal ()
toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, Maybe WebMemberProfile)
toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile])
toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, mentions, formattedText, quotedItem, reactions, file})
| isJust itemTimed = Nothing
| otherwise = case ciMsgContent content of
Just mc | not (isReport mc) ->
let (sender, senderProfile) = case chatDir of
CIGroupRcv m@GroupMember {memberId} -> (Just memberId, Just $ memberToProfile m)
_ -> (Nothing, Nothing)
CIGroupRcv m@GroupMember {memberId} -> (Just memberId, [memberToProfile m])
_ -> (Nothing, [])
quotedProfile = case quotedItem of
Just CIQuote {chatDir = CIQGroupRcv (Just m)} -> [memberToProfile m]
_ -> []
in Just
( WebMessage
{ sender,
@@ -330,7 +324,7 @@ toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed,
forward = if isJust itemForwarded then Just True else Nothing,
edited = itemEdited
},
senderProfile
senderProfile <> quotedProfile
)
_ -> Nothing
+6 -5
View File
@@ -1185,7 +1185,7 @@ function renderMessages(container, messages, membersMap, channel) {
}
const col = document.createElement('div');
const bubble = renderBubble(msg, member, showTail);
const bubble = renderBubble(msg, member, showTail, membersMap);
col.appendChild(bubble);
if (msg.reactions && msg.reactions.length > 0) {
@@ -1198,7 +1198,7 @@ function renderMessages(container, messages, membersMap, channel) {
}
}
function renderBubble(msg, member, showTail) {
function renderBubble(msg, member, showTail, membersMap) {
const mc = msg.content;
const mediaOnly = (mc.type === 'image' || mc.type === 'video') && !mc.text && !msg.quote && !msg.forward;
const noTailContent = (mc.type === 'image' || mc.type === 'video' || mc.type === 'voice') && !mc.text;
@@ -1225,7 +1225,7 @@ function renderBubble(msg, member, showTail) {
}
if (msg.quote) {
inner.appendChild(renderQuote(msg.quote));
inner.appendChild(renderQuote(msg.quote, membersMap));
}
switch (mc.type) {
@@ -1262,7 +1262,7 @@ function renderBubble(msg, member, showTail) {
return bubble;
}
function renderQuote(quote) {
function renderQuote(quote, membersMap) {
const quoteDiv = document.createElement('div');
quoteDiv.className = 'simplex-preview-quote';
@@ -1272,7 +1272,8 @@ function renderQuote(quote) {
if (quote.msgRef && quote.msgRef.memberId) {
const sender = document.createElement('div');
sender.className = 'simplex-preview-quote-sender';
sender.textContent = quote.msgRef.sent ? 'You' : (quote.msgRef.memberId || '');
const quotedMember = membersMap[quote.msgRef.memberId];
sender.textContent = quote.msgRef.sent ? 'You' : (quotedMember ? quotedMember.displayName : '');
contentDiv.appendChild(sender);
}
+12 -4
View File
@@ -23,6 +23,13 @@ function escapeAttr(text) {
.replace(/>/g, "&gt;");
}
const SAFE_URI_SCHEME = /^(https?:|simplex:|mailto:|tel:)/i;
function safeHref(uri) {
if (SAFE_URI_SCHEME.test(uri)) return escapeAttr(uri);
return `javascript:void(alert('Potentially malicious link blocked:\\n'+${JSON.stringify(uri)}))`;
}
function getSimplexLinkDescr(linkType) {
switch (linkType) {
case 'contact': return 'SimpleX contact address';
@@ -78,19 +85,20 @@ function renderMarkdown(fts) {
case 'colored':
html += `<span class="${(format.color || '').replace(/[^a-zA-Z0-9_-]/g, '')}">${escapeHtml(text)}</span>`;
break;
case 'uri':
case 'uri': {
let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text;
html += `<a href="${escapeAttr(href)}"${targetBlank(href)}>${escapeHtml(text)}</a>`;
html += `<a href="${safeHref(href)}"${targetBlank(href)}>${escapeHtml(text)}</a>`;
break;
}
case 'hyperLink': {
const { showText, linkUri } = format;
html += `<a href="${escapeAttr(linkUri)}"${targetBlank(linkUri)}>${escapeHtml(showText ?? linkUri)}</a>`;
html += `<a href="${safeHref(linkUri)}"${targetBlank(linkUri)}>${escapeHtml(showText ?? linkUri)}</a>`;
break;
}
case 'simplexLink': {
const { showText, linkType, simplexUri, smpHosts } = format;
const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType);
html += `<a href="${escapeAttr(platformSimplexUri(simplexUri))}" target="_blank">${linkText} <em>(${escapeHtml(viaHost(smpHosts))})</em></a>`;
html += `<a href="${safeHref(platformSimplexUri(simplexUri))}" target="_blank">${linkText} <em>(${escapeHtml(viaHost(smpHosts))})</em></a>`;
break;
}
case 'command':