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:
jack 2026-08-11 09:31:39 +02:00
parent 359139fc6b
commit a05c67252f
2 changed files with 61 additions and 5 deletions

View File

@ -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)

View File

@ -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()