Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit feed399

Browse files
committedMar 12, 2025·
phishing alert implementation
we now check when building the string through the `AttributedStringBuilder` if a URL is actually hiding a different link, if so, we create a custom URL that contains both the external and the internal URL to advise the user through an Alert about the risk
1 parent 16fa0bb commit feed399

File tree

4 files changed

+119
-24
lines changed

4 files changed

+119
-24
lines changed
 

‎ElementX/Sources/Application/Application.swift

+24-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import SwiftUI
1111
struct Application: App {
1212
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
1313
@Environment(\.openURL) private var openURL
14+
@State private var alert: AlertInfo<ApplicationAlertType>?
1415

1516
private var appCoordinator: AppCoordinatorProtocol!
1617

@@ -34,13 +35,21 @@ struct Application: App {
3435
if appCoordinator.handleDeepLink(url, isExternalURL: false) {
3536
return .handled
3637
}
38+
39+
if let confirmationParameters = url.confirmationParameters {
40+
alert = .init(id: .confirmURL,
41+
title: "Test",
42+
message: "Test",
43+
primaryButton: .init(title: "Confirm") { openURL(confirmationParameters.internalURL) },
44+
secondaryButton: .init(title: L10n.actionCancel, action: nil))
45+
46+
return .handled
47+
}
3748

3849
return .systemAction
3950
})
40-
.onOpenURL {
41-
if !appCoordinator.handleDeepLink($0, isExternalURL: true) {
42-
openURLInSystemBrowser($0)
43-
}
51+
.onOpenURL { url in
52+
openURL(url)
4453
}
4554
.onContinueUserActivity("INStartVideoCallIntent") { userActivity in
4655
// `INStartVideoCallIntent` is to be replaced with `INStartCallIntent`
@@ -50,10 +59,17 @@ struct Application: App {
5059
.task {
5160
appCoordinator.start()
5261
}
62+
.alert(item: $alert)
5363
}
5464
}
5565

5666
// MARK: - Private
67+
68+
private func openURL(_ url: URL) {
69+
if !appCoordinator.handleDeepLink(url, isExternalURL: true) {
70+
openURLInSystemBrowser(url)
71+
}
72+
}
5773

5874
/// Hide the status bar so it doesn't interfere with the screenshot tests
5975
private var shouldHideStatusBar: Bool {
@@ -81,3 +97,7 @@ struct Application: App {
8197
openURL(url)
8298
}
8399
}
100+
101+
enum ApplicationAlertType {
102+
case confirmURL
103+
}

‎ElementX/Sources/Other/Extensions/URL.swift

+32
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ extension URL: @retroactive ExpressibleByStringLiteral {
101101
return nil
102102
}
103103

104+
static let confirmationScheme = "confirm"
105+
106+
var confirmationParameters: ConfirmURLParameters? {
107+
guard scheme == Self.confirmationScheme,
108+
let queryItems = URLComponents(url: self, resolvingAgainstBaseURL: true)?.queryItems else {
109+
return nil
110+
}
111+
return ConfirmURLParameters(queryItems: queryItems)
112+
}
113+
104114
// MARK: Mocks
105115

106116
static var mockMXCAudio: URL { "mxc://matrix.org/1234567890AuDiO" }
@@ -110,3 +120,25 @@ extension URL: @retroactive ExpressibleByStringLiteral {
110120
static var mockMXCAvatar: URL { "mxc://matrix.org/1234567890AvAtAr" }
111121
static var mockMXCUserAvatar: URL { "mxc://matrix.org/1234567890AvAtArUsEr" }
112122
}
123+
124+
struct ConfirmURLParameters {
125+
let internalURL: URL
126+
let externalURL: String
127+
128+
var urlQueryItems: [URLQueryItem] {
129+
[URLQueryItem(name: "internalURL", value: internalURL.absoluteString),
130+
URLQueryItem(name: "externalURL", value: externalURL)]
131+
}
132+
}
133+
134+
extension ConfirmURLParameters {
135+
init?(queryItems: [URLQueryItem]) {
136+
guard let internalURLString = queryItems.first(where: { $0.name == "internalURL" })?.value,
137+
let internalURL = URL(string: internalURLString),
138+
let externalURLString = queryItems.first(where: { $0.name == "externalURL" })?.value else {
139+
return nil
140+
}
141+
externalURL = externalURLString
142+
self.internalURL = internalURL
143+
}
144+
}

‎ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift

+57-14
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
4242

4343
let mutableAttributedString = NSMutableAttributedString(string: string)
4444
removeDefaultForegroundColors(mutableAttributedString)
45+
detectPhishingAttempts(mutableAttributedString)
4546
addLinksAndMentions(mutableAttributedString)
4647
detectPermalinks(mutableAttributedString)
4748

@@ -98,6 +99,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
9899

99100
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
100101
removeDefaultForegroundColors(mutableAttributedString)
102+
detectPhishingAttempts(mutableAttributedString)
101103
addLinksAndMentions(mutableAttributedString)
102104
replaceMarkedBlockquotes(mutableAttributedString)
103105
replaceMarkedCodeBlocks(mutableAttributedString)
@@ -135,6 +137,22 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
135137
attributedString.removeAttribute(.foregroundColor, range: .init(location: 0, length: attributedString.length))
136138
}
137139

140+
private func detectPhishingAttempts(_ attributedString: NSMutableAttributedString) {
141+
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
142+
guard value != nil, let internalURL = value as? URL else {
143+
return
144+
}
145+
146+
var linkString = attributedString.attributedSubstring(from: range).string
147+
guard MatrixEntityRegex.linkRegex.firstMatch(in: linkString) != nil,
148+
linkString != internalURL.absoluteString else {
149+
return
150+
}
151+
152+
handlePhishingAttempt(for: attributedString, in: range, internalURL: internalURL, externalURL: linkString)
153+
}
154+
}
155+
138156
// swiftlint:disable:next cyclomatic_complexity
139157
private func addLinksAndMentions(_ attributedString: NSMutableAttributedString) {
140158
let string = attributedString.string
@@ -177,19 +195,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
177195
return nil
178196
}
179197

180-
var link = String(string[matchRange])
181-
182-
if !link.contains("://") {
183-
link.insert(contentsOf: "https://", at: link.startIndex)
184-
}
185-
186-
// Don't include punctuation characters at the end of links
187-
// e.g `https://element.io/blog:` <- which is a valid link but the wrong place
188-
while !link.isEmpty,
189-
link.rangeOfCharacter(from: .punctuationCharacters, options: .backwards)?.upperBound == link.endIndex {
190-
link = String(link.dropLast())
191-
}
192-
198+
let link = sanitizeLink(String(string[matchRange]))
193199
return TextParsingMatch(type: .link(urlString: link), range: match.range)
194200
})
195201

@@ -278,7 +284,8 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
278284
func detectPermalinks(_ attributedString: NSMutableAttributedString) {
279285
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
280286
if value != nil {
281-
if let url = value as? URL, let matrixEntity = parseMatrixEntityFrom(uri: url.absoluteString) {
287+
if let url = value as? URL,
288+
let matrixEntity = parseMatrixEntityFrom(uri: url.absoluteString) {
282289
switch matrixEntity.id {
283290
case .user(let userID):
284291
mentionBuilder.handleUserMention(for: attributedString, in: range, url: url, userID: userID, userDisplayName: nil)
@@ -303,6 +310,42 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
303310
}
304311
}
305312

313+
private func sanitizeLink(_ string: String) -> String {
314+
var link = string
315+
if !link.contains("://") {
316+
link.insert(contentsOf: "https://", at: link.startIndex)
317+
}
318+
319+
// Don't include punctuation characters at the end of links
320+
// e.g `https://element.io/blog:` <- which is a valid link but the wrong place
321+
while !link.isEmpty,
322+
link.rangeOfCharacter(from: .punctuationCharacters, options: .backwards)?.upperBound == link.endIndex {
323+
link = String(link.dropLast())
324+
}
325+
326+
return link
327+
}
328+
329+
private func handlePhishingAttempt(for attributedString: NSMutableAttributedString,
330+
in range: NSRange,
331+
internalURL: URL,
332+
externalURL: String) {
333+
// Let's remove the existing link attribute
334+
attributedString.removeAttribute(.link, range: range)
335+
336+
var urlComponents = URLComponents()
337+
urlComponents.scheme = URL.confirmationScheme
338+
urlComponents.host = ""
339+
let parameters = ConfirmURLParameters(internalURL: internalURL, externalURL: externalURL)
340+
urlComponents.queryItems = parameters.urlQueryItems
341+
342+
guard let finalURL = urlComponents.url else {
343+
return
344+
}
345+
346+
attributedString.addAttribute(.link, value: finalURL, range: range)
347+
}
348+
306349
private func removeDTCoreTextArtifacts(_ attributedString: NSMutableAttributedString) {
307350
guard attributedString.length > 0 else {
308351
return

‎UnitTests/Sources/AttributedStringBuilderTests.swift

+6-6
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,9 @@ class AttributedStringBuilderTests: XCTestCase {
136136
}
137137

138138
func testRenderHTMLStringWithLinkInHeader() {
139-
let h1HTMLString = "<h1><a href=\"https://www.matrix.org/\">Matrix.org</a></h1>"
140-
let h2HTMLString = "<h2><a href=\"https://www.matrix.org/\">Matrix.org</a></h2>"
141-
let h3HTMLString = "<h3><a href=\"https://www.matrix.org/\">Matrix.org</a></h3>"
139+
let h1HTMLString = "<h1><a href=\"https://www.matrix.org/\">Matrix</a></h1>"
140+
let h2HTMLString = "<h2><a href=\"https://www.matrix.org/\">Matrix</a></h2>"
141+
let h3HTMLString = "<h3><a href=\"https://www.matrix.org/\">Matrix</a></h3>"
142142

143143
guard let h1AttributedString = attributedStringBuilder.fromHTML(h1HTMLString),
144144
let h2AttributedString = attributedStringBuilder.fromHTML(h2HTMLString),
@@ -147,9 +147,9 @@ class AttributedStringBuilderTests: XCTestCase {
147147
return
148148
}
149149

150-
XCTAssertEqual(String(h1AttributedString.characters), "Matrix.org")
151-
XCTAssertEqual(String(h2AttributedString.characters), "Matrix.org")
152-
XCTAssertEqual(String(h3AttributedString.characters), "Matrix.org")
150+
XCTAssertEqual(String(h1AttributedString.characters), "Matrix")
151+
XCTAssertEqual(String(h2AttributedString.characters), "Matrix")
152+
XCTAssertEqual(String(h3AttributedString.characters), "Matrix")
153153

154154
XCTAssertEqual(h1AttributedString.runs.count, 1)
155155
XCTAssertEqual(h2AttributedString.runs.count, 1)

0 commit comments

Comments
 (0)
Please sign in to comment.