Skip to content

Commit b1fb664

Browse files
committed
Sanitize bidi characters (#185)
* WIP Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * Finalize Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> * Fix PR issues Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com> --------- Signed-off-by: Milen Pivchev <milen.pivchev@gmail.com>
1 parent 0c3f863 commit b1fb664

File tree

2 files changed

+109
-0
lines changed

2 files changed

+109
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Milen Pivchev
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import Foundation
6+
7+
extension String {
8+
public func sanitizeForBidiCharacters(isFolder: Bool, isRTL: Bool = false) -> String {
9+
let ns = self as NSString
10+
let base = ns.deletingPathExtension
11+
let ext = ns.pathExtension
12+
13+
guard !ext.isEmpty else { return base }
14+
15+
let dangerousBidiScalars: Set<UInt32> = [
16+
0x202A, 0x202B, 0x202C, 0x202D, 0x202E,
17+
0x200E, 0x200F, 0x2066, 0x2067, 0x2068,
18+
0x2069, 0x061C
19+
]
20+
let containsBidi = base.unicodeScalars.contains { dangerousBidiScalars.contains($0.value) }
21+
22+
if isRTL {
23+
if containsBidi {
24+
return "\u{202C}\u{2066}.\(ext)\u{2069}" + base
25+
} else {
26+
return ".\(ext)" + base
27+
}
28+
} else {
29+
if containsBidi {
30+
return base + "\u{202C}\u{2066}.\(ext)\u{2069}"
31+
} else {
32+
return base + "." + ext
33+
}
34+
}
35+
}
36+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Milen Pivchev
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import Testing
6+
import Foundation
7+
@testable import NextcloudKit
8+
9+
@Suite(.serialized) struct FileSanitizingUnitTests {
10+
// MARK: - Helper for test expectation
11+
func expectedSanitized(for filename: String, isFolder: Bool, isRTL: Bool) -> String {
12+
let ns = filename as NSString
13+
let base = ns.deletingPathExtension
14+
let ext = ns.pathExtension
15+
16+
if isFolder || ext.isEmpty { return base }
17+
18+
let dangerousBidiScalars: Set<UInt32> = [
19+
0x202A, 0x202B, 0x202C, 0x202D, 0x202E,
20+
0x200E, 0x200F, 0x2066, 0x2067, 0x2068,
21+
0x2069, 0x061C
22+
]
23+
let containsBidi = base.unicodeScalars.contains { dangerousBidiScalars.contains($0.value) }
24+
25+
if isRTL {
26+
return containsBidi
27+
? "\u{202C}\u{2066}.\(ext)\u{2069}" + base
28+
: ".\(ext)" + base
29+
} else {
30+
return containsBidi
31+
? base + "\u{202C}\u{2066}.\(ext)\u{2069}"
32+
: base + "." + ext
33+
}
34+
}
35+
36+
// MARK: - Test Cases
37+
@Test
38+
func testSanitizeForBidiCharacters_UIRendering() {
39+
let cases: [(String, Bool, Bool)] = [
40+
// LTR, normal and malicious
41+
("invoice\u{202E}cod.exe", false, false), // malicious RLO
42+
("archive.tar.gz", false, false), // multiple dots
43+
("myFolder", true, false), // folder
44+
("document.txt", false, false), // normal file
45+
("Foo\u{202E}dm.exe", false, false), // another malicious
46+
47+
// RTL Hebrew / Arabic safe
48+
("תמונה.jpg", false, true), // Hebrew base
49+
("מכתב.pdf", false, true), // Hebrew base
50+
("שלום", true, true), // Hebrew folder
51+
("مرحبا", true, true), // Arabic folder
52+
("ملف.pdf", false, true), // Arabic file
53+
54+
// Mixed-language
55+
("report.ملف", false, true), // English base, Arabic extension
56+
("وثيقة.docx", false, true), // Arabic base, English extension
57+
("summary.תמונה", false, true), // English base, Hebrew extension
58+
("מסמך.txt", false, true), // Hebrew base, English extension
59+
60+
// Mixed-language with malicious bidi
61+
("report\u{202E}cod.exe", false, true), // English base + RLO trick
62+
("ملف\u{202E}cod.exe", false, true), // Arabic base + RLO trick
63+
("תמונה\u{202E}cod.exe", false, true) // Hebrew base + RLO trick
64+
]
65+
66+
for (filename, isFolder, isRTL) in cases {
67+
let result = filename.sanitizeForBidiCharacters(isFolder: isFolder, isRTL: isRTL)
68+
let expected = expectedSanitized(for: filename, isFolder: isFolder, isRTL: isRTL)
69+
#expect(result == expected, "Failed for filename: \(filename), isFolder: \(isFolder), isRTL: \(isRTL)")
70+
}
71+
}
72+
}
73+

0 commit comments

Comments
 (0)