mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-25 07:41:41 +00:00
Close the system-message spoofing hole in the action parser
processActionMessage rewrote the sender to "system" — the formatter's trusted styling — for ANY `* … *` content containing 🫂, 🐟, or the substring "took a screenshot". A hostile peer could render arbitrary text as a system-authored line: `* SECURITY: your session key expired, re-verify at evil.example — bob took a screenshot *`. Only the exact locally-generatable action shapes qualify now, and the actor slot must equal the actual wire sender: a peer can hug, slap, or screenshot only as themselves, and the target slot is bounded, single-line, and nickname-shaped. Same templates iOS and Android emit, so legit actions render unchanged; anything else falls through as an ordinary peer message under the sender's real name. Pinned by tests covering the legit shapes, the original spoof payload, actor-mismatch, and free-text-in-target-slot cases. No new strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
359139fc6b
commit
a05c67252f
@ -823,11 +823,21 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
|
||||
let isActionMessage = message.content.hasPrefix("* ")
|
||||
&& message.content.hasSuffix(" *")
|
||||
&& (message.content.contains("🫂")
|
||||
|| message.content.contains("🐟")
|
||||
|| message.content.contains("took a screenshot"))
|
||||
// This rewrite hands the message to the formatter as sender "system",
|
||||
// which styles it as trusted, non-peer content. Substring sniffing
|
||||
// ("contains 🫂") let ANY sender put arbitrary text in that styling:
|
||||
// `* SECURITY: session expired — bob took a screenshot *` rendered as
|
||||
// a system-authored line. Only the exact, locally-generatable action
|
||||
// shapes qualify, and the actor slot must be the actual wire sender —
|
||||
// a peer can only "hug"/"slap"/"screenshot" as themselves.
|
||||
guard message.content.hasPrefix("* "), message.content.hasSuffix(" *") else { return message }
|
||||
let inner = String(message.content.dropFirst(2).dropLast(2))
|
||||
let sender = message.sender
|
||||
|
||||
let isActionMessage =
|
||||
Self.matchesActionTemplate(inner, prefix: "🫂 \(sender) hugs ", suffix: "")
|
||||
|| Self.matchesActionTemplate(inner, prefix: "🐟 \(sender) slaps ", suffix: " around a bit with a large trout")
|
||||
|| inner == "\(sender) took a screenshot"
|
||||
|
||||
guard isActionMessage else { return message }
|
||||
|
||||
@ -846,6 +856,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
/// The target slot of an action template: bounded, single-line, and
|
||||
/// non-empty — a nickname or the literal "you", never free text.
|
||||
static func matchesActionTemplate(_ inner: String, prefix: String, suffix: String) -> Bool {
|
||||
guard inner.hasPrefix(prefix), inner.hasSuffix(suffix),
|
||||
inner.count >= prefix.count + suffix.count + 1 else { return false }
|
||||
let target = inner.dropFirst(prefix.count).dropLast(suffix.count)
|
||||
return !target.isEmpty && target.count <= 64 && !target.contains("\n")
|
||||
}
|
||||
|
||||
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
|
||||
let currentFingerprint = context.getFingerprint(for: peerID)
|
||||
|
||||
|
||||
@ -327,6 +327,43 @@ private func makeFavoriteRelationship(
|
||||
/// live singletons.
|
||||
struct ChatPrivateConversationCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func processActionMessage_rendersOnlyExactSenderAnchoredTemplates() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
|
||||
func processed(_ content: String, sender: String = "bob") -> BitchatMessage {
|
||||
coordinator.processActionMessage(
|
||||
BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: sender,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Exact locally-generatable shapes, actor == wire sender: system.
|
||||
#expect(processed("* 🫂 bob hugs alice *").sender == "system")
|
||||
#expect(processed("* 🫂 bob hugs you *").sender == "system")
|
||||
#expect(processed("* 🐟 bob slaps alice around a bit with a large trout *").sender == "system")
|
||||
#expect(processed("* bob took a screenshot *").sender == "system")
|
||||
|
||||
// The spoof this parser used to allow: arbitrary text between the
|
||||
// markers with a magic substring rendered as system-authored.
|
||||
#expect(processed("* SECURITY: your session key expired, re-verify at evil.example — bob took a screenshot *").sender == "bob")
|
||||
#expect(processed("* 🫂 admin hugs alice — send your keys to @admin *").sender == "bob")
|
||||
// Actor slot must be the actual sender, not someone else's name.
|
||||
#expect(processed("* alice took a screenshot *", sender: "bob").sender == "bob")
|
||||
#expect(processed("* 🫂 alice hugs you *", sender: "bob").sender == "bob")
|
||||
// Free text smuggled into the target slot: bounded, single-line only.
|
||||
#expect(processed("* 🫂 bob hugs " + String(repeating: "x", count: 200) + " *").sender == "bob")
|
||||
#expect(processed("* 🫂 bob hugs a\nb *").sender == "bob")
|
||||
// Not an action shape at all.
|
||||
#expect(processed("hello there").sender == "bob")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func addMessageToPrivateChats_upsertsByIdAndSanitizes() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user