From e32dee5e654c9836475d8117ec89dbbdd976cb50 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:08:54 +0400 Subject: [PATCH] wip --- src/Simplex/Chat/Library/Subscriber.hs | 34 +++++----- src/Simplex/Chat/Messages/Batch.hs | 5 +- src/Simplex/Chat/Store/Delivery.hs | 3 +- tests/ChatTests/Groups.hs | 87 +++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2e19508833..31677b3928 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2143,21 +2143,25 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createContentItem gInfo Nothing Nothing -- no delivery task - message already forwarded by relay pure Nothing - Just m@GroupMember {memberId} -> do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m content msgScope_ - if blockedByAdmin m' - then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing - else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of - Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing - Nothing -> - withStore' (\db -> getCIModeration db cxt user gInfo' memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration gInfo' m' scopeInfo ciModeration - withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ - pure Nothing - Nothing -> do - createContentItem gInfo' (Just m') scopeInfo - pure $ Just $ infoToDeliveryContext gInfo' scopeInfo sentAsGroup + Just m@GroupMember {memberId} + -- only an owner may post as the channel; a non-owner's signed asGroup post (e.g. relay-injected) must not render as the channel + | sentAsGroup && memberRole' m < GROwner -> + messageError "x.msg.new: member is not allowed to send as group" $> Nothing + | otherwise -> do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m content msgScope_ + if blockedByAdmin m' + then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing + else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of + Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing + Nothing -> + withStore' (\db -> getCIModeration db cxt user gInfo' memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration gInfo' m' scopeInfo ciModeration + withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ + pure Nothing + Nothing -> do + createContentItem gInfo' (Just m') scopeInfo + pure $ Just $ infoToDeliveryContext gInfo' scopeInfo sentAsGroup where rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False timed_ gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 81861aad74..987c0b40bc 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -105,7 +105,10 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString -encodeFwdElement fwd verifiedMsg = ">" <> smpEncode fwd <> encodeBatchElement signedMsg_ msgBody +encodeFwdElement fwd@GrpMsgForward {fwdSender} verifiedMsg + | FwdChannel <- fwdSender, isJust signedMsg_ = + error "encodeFwdElement: signed message must forward as FwdMember, not FwdChannel" + | otherwise = ">" <> smpEncode fwd <> encodeBatchElement signedMsg_ msgBody where (_, signedMsg_, msgBody) = verifiedMsgParts verifiedMsg diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 4600c13004..add8c64e7f 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -33,6 +33,7 @@ import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import qualified Data.List.NonEmpty as L +import Data.Maybe (isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) @@ -155,7 +156,7 @@ getMsgDeliveryTask_ db taskId = toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, Binary msgBody, chatBinding_, sigs_, BI showGroupAsSender)) = case (toJobScope_ jobScopeRow, J.eitherDecodeStrict' msgBody) of (Just jobScope, Right chatMsg) -> - let fwdSender = if showGroupAsSender then FwdChannel else FwdMember senderMemberId senderMemberName + let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId senderMemberName -- Re-parsed from msg_body: validates stored content against current code. -- Signed: original bytes preserved (re-encoding would invalidate signature). -- Unsigned: re-encoded from parsed ChatMessage on forward (sanitizes content). diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 60650e5e00..f48bf0fcb2 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -36,7 +36,7 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate, XMsgNew), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -334,6 +334,8 @@ chatGroupTests = do it "should delete member message in channel" testChannelMemberMessageDelete it "should sign member message and reuse signature on edit" testChannelMemberMessageSign it "should reject unsigned update of a signed item" testChannelMemberUpdateEnforcement + it "should sign as-channel post and keep it displayed as the channel" testChannelAsGroupSign + it "should reject a non-owner posting as the channel" testChannelAsGroupSpoof testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -12201,6 +12203,89 @@ testChannelMemberUpdateEnforcement ps = (Only smid : _) -> pure (SharedMsgId smid) _ -> fail "no shared_msg_id" +testChannelAsGroupSign :: HasCallStack => TestParams -> IO () +testChannelAsGroupSign ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner posts as the channel, signed: verifiable AND displayed as the channel + alice ##> "/_send #1(as_group=on) sign=on text signed channel post" + alice <# "#team signed channel post" + bob <# "#team> signed channel post" + [cath, dan, eve] *<# "#team> signed channel post [>>]" + alice #$> ("/_get chat #1 count=1", chat, [(1, "signed channel post (signed)")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "signed channel post (signed)")]) + + -- owner posts as the channel, unsigned: anonymous (FwdChannel), no signature, still as the channel + alice ##> "/_send #1(as_group=on) text plain channel post" + alice <# "#team plain channel post" + bob <# "#team> plain channel post" + [cath, dan, eve] *<# "#team> plain channel post [>>]" + alice #$> ("/_get chat #1 count=1", chat, [(1, "plain channel post")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "plain channel post")]) + +testChannelAsGroupSpoof :: HasCallStack => TestParams -> IO () +testChannelAsGroupSpoof ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- cath posts legitimately (introduces cath to dan as a member) + cath #> "#team hi from cath" + bob <# "#team cath> hi from cath" + concurrentlyN_ + [ alice <# "#team cath> hi from cath [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi from cath [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> hi from cath [>>]" + ] + + -- the relay forges an asGroup=True post attributed to non-owner cath; dan rejects (owner guard, ยง2) + cathMemId <- memberIdByName bob "cath" + connId <- relayConnIdToMember bob "dan" + ts <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + container = (mcSimple (MCText "fake channel announcement")) {asGroup = Just True} + chatMsg = ChatMessage chatInitialVRange Nothing (XMsgNew container) + fwd = GrpMsgForward (FwdMember cathMemId "cath") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + dan <##. "error: x.msg.new: member is not allowed to send as group" + -- not rendered as the channel: dan still holds only the legitimate member message + threadDelay 1000000 + dan #$> ("/_get chat #1 count=1", chat, [(0, "hi from cath")]) + where + memberIdByName :: TestCC -> T.Text -> IO MemberId + memberIdByName cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only ByteString] + case rows of + (Only mid : _) -> pure (MemberId mid) + _ -> fail $ "no member " <> T.unpack name + relayConnIdToMember :: TestCC -> T.Text -> IO ByteString + relayConnIdToMember cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + "SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?" + (Only name) :: + IO [Only ByteString] + case rows of + (Only connId : _) -> pure connId + _ -> fail $ "no relay connection to member " <> T.unpack name + testGroupLinkContentFilter :: HasCallStack => TestParams -> IO () testGroupLinkContentFilter = testChat3 aliceProfile bobProfile cathProfile $