improve security

This commit is contained in:
Evgeny @ SimpleX Chat
2026-06-04 16:25:53 +00:00
parent 6f539d8786
commit 53cea75afd
4 changed files with 71 additions and 15 deletions
+7 -2
View File
@@ -843,8 +843,13 @@ instance FromJSON ImageData where
parseJSON = fmap ImageData . J.parseJSON
instance ToJSON ImageData where
toJSON (ImageData t) = J.toJSON t
toEncoding (ImageData t) = J.toEncoding t
toJSON (ImageData t) = J.toJSON $ safeImageData t
toEncoding (ImageData t) = J.toEncoding $ safeImageData t
safeImageData :: Text -> Text
safeImageData t
| "data:" `T.isPrefixOf` t = t
| otherwise = ""
instance ToField ImageData where toField (ImageData t) = toField t
+13 -5
View File
@@ -20,6 +20,7 @@ module Simplex.Chat.Web
channelContentChanged,
channelProfileUpdated,
channelRemoved,
extractOrigin,
)
where
@@ -34,6 +35,7 @@ import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import Data.Text.Encoding (encodeUtf8)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import qualified Data.Set as S
@@ -76,6 +78,8 @@ import Simplex.Chat.Types
)
import Simplex.Messaging.Agent.Store.Common (withTransaction)
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (safeDecodeUtf8)
import qualified URI.ByteString as U
import Simplex.Messaging.Parsers (defaultJSON)
import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile)
import System.FilePath (dropExtension, takeExtension, (</>))
@@ -369,13 +373,17 @@ corsEntry publicGroupId PublicGroupAccess {groupWebPage, allowEmbedding} =
let fName = T.pack $ publicGroupIdFileName publicGroupId <> ".json"
origin
| allowEmbedding = CorsAny
| otherwise = CorsOrigins $ filter isSafeOrigin $ maybeToList groupWebPage
| otherwise = CorsOrigins $ mapMaybe extractOrigin $ maybeToList groupWebPage
in (fName, origin)
isSafeOrigin :: Text -> Bool
isSafeOrigin t =
(T.isPrefixOf "https://" t || T.isPrefixOf "http://" t)
&& T.all (\c -> c /= '"' && c /= '\n' && c /= '\r' && c /= ' ') t
extractOrigin :: Text -> Maybe Text
extractOrigin url =
case U.parseURI U.laxURIParserOptions (encodeUtf8 url) of
Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority = Just _}
| sch == "https" || sch == "http" ->
let originUri = uri {U.uriPath = "", U.uriQuery = U.Query [], U.uriFragment = Nothing}
in Just $ safeDecodeUtf8 $ U.serializeURIRef' originUri
_ -> Nothing
channelPath :: Text
channelPath = "/channel/"
+12 -1
View File
@@ -19,7 +19,7 @@ import ProtocolTests (testGroupProfile)
import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..))
import Simplex.Chat.Types (GroupProfile (..))
import Simplex.Chat.Controller (CorsOrigin (..))
import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), removeStaleFiles, writeCorsConfig)
import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig)
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import Simplex.Messaging.Util (decodeJSON)
import qualified Data.Set as S
@@ -47,6 +47,7 @@ chatRelayTests = do
it "channel deletion removes preview file" testWebPreviewChannelDeleted
it "removeStaleFiles preserves non-base64url files" testWebPreviewStaleCleanup
it "generate CORS config" testWebPreviewCors
it "extractOrigin strips path from URL" testExtractOrigin
describe "share channel card" $ do
it "share channel card in direct chat" testShareChannelDirect
it "share channel card in group" testShareChannelGroup
@@ -566,6 +567,16 @@ testWebPreviewCors ps = do
corsContent `shouldContain` "Access-Control-Allow-Origin"
corsContent `shouldContain` "Access-Control-Allow-Methods"
testExtractOrigin :: HasCallStack => TestParams -> IO ()
testExtractOrigin _ps = do
extractOrigin "https://owner.example.com/channel.html" `shouldBe` Just "https://owner.example.com"
extractOrigin "https://owner.example.com/path/to/page?q=1#frag" `shouldBe` Just "https://owner.example.com"
extractOrigin "https://owner.example.com:8443/page" `shouldBe` Just "https://owner.example.com:8443"
extractOrigin "https://owner.example.com" `shouldBe` Just "https://owner.example.com"
extractOrigin "http://localhost:3000/preview" `shouldBe` Just "http://localhost:3000"
extractOrigin "ftp://example.com/file" `shouldBe` Nothing
extractOrigin "not-a-url" `shouldBe` Nothing
-- Create a public group with relay=1, wait for relay to join
createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO ()
createChannelWithRelay gName owner relay = do
+39 -7
View File
@@ -327,6 +327,22 @@ const STYLE = `
width: 300px;
}
.simplex-preview-image-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 80px;
background: var(--sp-quote);
border-radius: 12px;
color: var(--sp-text-secondary);
}
.simplex-preview-image-placeholder svg {
width: 32px;
height: 32px;
}
.simplex-preview-link-card {
display: block;
max-width: 400px;
@@ -714,6 +730,12 @@ const STYLE = `
const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25.8 25.46"><circle cx="12.9" cy="12.73" r="12.72" fill="#eee"/><path d="M25.44 12.73c0 7.01-5.71 12.73-12.73 12.73C5.71 25.46 0 19.75 0 12.73 0 5.73 5.71.02 12.71.02c7.01 0 12.73 5.71 12.73 12.72zM4.22 17.11c0 .58.31.87 1.05.87h4.88c-.14-.26-.19-.55-.19-.82 0-1.03.59-2.19 1.63-3.1-.79-.5-1.75-.79-2.74-.79-2.37 0-4.63 1.68-4.63 3.84zm6.6.05c0 .56.36.82 1.27.82h8.1c.93 0 1.26-.26 1.26-.82 0-1.62-2.02-3.87-5.28-3.87-3.29 0-5.34 2.25-5.34 3.87zM6.66 9.87c0 1.37 1.01 2.42 2.19 2.42 1.2 0 2.2-1.05 2.2-2.43 0-1.33-1.02-2.36-2.2-2.36-1.16 0-2.19 1.05-2.19 2.37zm6.98-.5c0 1.56 1.13 2.78 2.52 2.78 1.36 0 2.5-1.22 2.5-2.79 0-1.53-1.15-2.71-2.5-2.71-1.38 0-2.52 1.21-2.52 2.73z" fill="#ccc" fill-opacity=".85"/></svg>');
const IMAGE_PLACEHOLDER_SVG = `<svg class="simplex-preview-file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
function isDataImage(src) {
return typeof src === 'string' && src.startsWith('data:image/');
}
function tailSvg() {
return '<svg width="9" height="16" viewBox="0 0 9 16" xmlns="http://www.w3.org/2000/svg"><path d="M9 0 L9 16 L0 16 Q9 11 9 0 Z" fill="currentColor"/></svg>';
}
@@ -881,7 +903,7 @@ function renderHeader(channel, channelLink, subscriberCount) {
const avatar = document.createElement('img');
avatar.className = 'simplex-preview-header-avatar';
avatar.src = channel.image || DEFAULT_AVATAR;
avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR;
avatar.alt = channel.displayName;
header.appendChild(avatar);
@@ -919,7 +941,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp
const avatar = document.createElement('img');
avatar.className = 'simplex-preview-info-avatar';
avatar.src = channel.image || DEFAULT_AVATAR;
avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR;
avatar.alt = channel.displayName;
container.appendChild(avatar);
@@ -1152,7 +1174,7 @@ function renderMessages(container, messages, membersMap, channel) {
if (showAvatar) {
const avatarImg = document.createElement('img');
avatarImg.className = 'simplex-preview-msg-avatar';
avatarImg.src = senderImage || DEFAULT_AVATAR;
avatarImg.src = isDataImage(senderImage) ? senderImage : DEFAULT_AVATAR;
avatarImg.alt = senderName;
row.appendChild(avatarImg);
} else {
@@ -1262,7 +1284,7 @@ function renderQuote(quote) {
quoteDiv.appendChild(contentDiv);
if (quote.content) {
if (quote.content.type === 'image' || quote.content.type === 'video') {
if ((quote.content.type === 'image' || quote.content.type === 'video') && isDataImage(quote.content.image)) {
const thumb = document.createElement('img');
thumb.className = 'simplex-preview-quote-thumb';
thumb.src = quote.content.image;
@@ -1290,13 +1312,18 @@ function classifyImage(img) {
}
function renderImageContent(inner, mc, msg, mediaOnly) {
if (mc.image) {
if (isDataImage(mc.image)) {
const img = document.createElement('img');
img.className = 'simplex-preview-image';
img.src = mc.image;
img.alt = 'Image';
img.addEventListener('load', () => classifyImage(img));
inner.appendChild(img);
} else {
const ph = document.createElement('div');
ph.className = 'simplex-preview-image-placeholder';
ph.innerHTML = IMAGE_PLACEHOLDER_SVG;
inner.appendChild(ph);
}
if (mc.text) {
appendTextBlock(inner, msg);
@@ -1306,7 +1333,7 @@ function renderImageContent(inner, mc, msg, mediaOnly) {
}
function renderVideoContent(inner, mc, msg, mediaOnly) {
if (mc.image) {
if (isDataImage(mc.image)) {
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
const img = document.createElement('img');
@@ -1320,6 +1347,11 @@ function renderVideoContent(inner, mc, msg, mediaOnly) {
dur.textContent = formatDuration(mc.duration || 0);
wrapper.appendChild(dur);
inner.appendChild(wrapper);
} else {
const ph = document.createElement('div');
ph.className = 'simplex-preview-image-placeholder';
ph.innerHTML = IMAGE_PLACEHOLDER_SVG;
inner.appendChild(ph);
}
if (mc.text) {
appendTextBlock(inner, msg);
@@ -1332,7 +1364,7 @@ function renderLinkContent(bubble, mc, msg) {
if (mc.preview) {
const card = document.createElement('div');
card.className = 'simplex-preview-link-card';
if (mc.preview.image) {
if (isDataImage(mc.preview.image)) {
const img = document.createElement('img');
img.className = 'simplex-preview-link-card-image';
img.src = mc.preview.image;