mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-29 03:18:37 +00:00
improve security
This commit is contained in:
@@ -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
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user