@sun-asterisk/sunlint 1.3.48 → 1.3.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/file-targeting-service.js +148 -15
- package/core/init-command.js +118 -70
- package/core/project-detector.js +517 -0
- package/core/tui-select.js +245 -0
- package/engines/arch-detect/rules/layered/l001-presentation-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l002-business-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l003-data-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l004-model-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l005-layer-separation.js +22 -2
- package/engines/arch-detect/rules/layered/l006-dependency-direction.js +8 -5
- package/engines/arch-detect/rules/modular/m005-no-deep-imports.js +67 -29
- package/engines/arch-detect/rules/presentation/pr001-view-layer.js +16 -9
- package/engines/arch-detect/rules/presentation/pr006-router-layer.js +33 -8
- package/engines/arch-detect/rules/presentation/pr007-interactor-layer.js +35 -6
- package/engines/arch-detect/rules/project-scanner/ps003-framework-detection.js +56 -10
- package/package.json +1 -1
- package/skill-assets/sunlint-code-quality/rules/dart/C006-verb-noun-functions.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C013-no-dead-code.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C014-dependency-injection.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C017-no-constructor-logic.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C018-generic-errors.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C019-error-log-level.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C020-no-unused-imports.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C022-no-unused-variables.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C023-no-duplicate-names.md +56 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C024-centralize-constants.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C029-catch-log-root-cause.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C030-custom-error-classes.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C033-separate-data-access.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C035-error-context-logging.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C041-no-hardcoded-secrets.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C042-boolean-naming.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C052-widget-parsing.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C060-superclass-logic.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C067-no-hardcoded-config.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/AGENTS.md +149 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN001-abort-after-response.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN002-request-context.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN003-bind-error-handling.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN004-dependency-injection.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN005-route-groups-middleware.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN006-http-status-codes.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN007-release-mode.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN008-struct-validation-tags.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN009-recovery-middleware.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN010-context-scope.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN011-middleware-concerns.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN012-no-log-sensitive.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/java/J001-try-with-resources.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/java/J002-equals-and-hashcode.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J003-string-comparison.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/java/J004-use-java-time.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J005-no-print-stack-trace.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/java/J006-no-system-println.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/java/J007-proper-logger.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J008-thread-safe-singleton.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J009-utility-class-constructor.md +82 -0
- package/skill-assets/sunlint-code-quality/rules/java/J010-preserve-stack-trace.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J011-null-safe-compare.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J012-use-enum-collections.md +104 -0
- package/skill-assets/sunlint-code-quality/rules/java/J013-return-empty-not-null.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/java/J014-hardcoded-crypto-key.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/java/J015-optional-instead-of-null.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/AGENTS.md +124 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV001-form-request-validation.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV002-eager-load-no-n-plus-1.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV003-config-not-env.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV004-fillable-mass-assignment.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV005-policies-gates-authorization.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV006-queue-heavy-tasks.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV007-hash-passwords.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV008-route-model-binding.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV009-api-resources.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV010-chunk-large-datasets.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV011-db-transactions.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV012-service-layer.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV013-testing-factories.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV014-service-container.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/python/P001-mutable-default-argument.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/python/P002-specify-file-encoding.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/python/P003-context-manager-for-resources.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/python/P004-no-bare-except.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/python/P005-use-isinstance.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/python/P006-timezone-aware-datetime.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/python/P007-use-pathlib.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/python/P008-no-wildcard-import.md +52 -0
- package/skill-assets/sunlint-code-quality/rules/python/P009-logging-lazy-format.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/python/P010-exception-chaining.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/python/P011-subprocess-check.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/python/P012-requests-timeout.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/python/P013-no-global-statement.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/python/P014-no-modify-collection-while-iterating.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/python/P015-prefer-fstrings.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/AGENTS.md +121 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR001-strong-parameters.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR002-eager-load-includes.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR003-service-objects.md +99 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR004-active-job-background.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR005-pagination.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR006-find-each-batches.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR007-http-status-codes.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR008-before-action-auth.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR009-rails-credentials.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR010-scopes.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR011-counter-cache.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR012-render-json-status.md +42 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C006-verb-noun-functions.md +37 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C013-no-dead-code.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C014-dependency-injection.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C017-no-constructor-logic.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C018-generic-errors.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C019-error-log-level.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C020-no-unused-imports.md +47 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C022-no-unused-variables.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C023-no-duplicate-names.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C024-centralize-constants.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C029-catch-log-root-cause.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C030-custom-error-classes.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C033-separate-data-access.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C035-error-context-logging.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C041-no-hardcoded-secrets.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C042-boolean-naming.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C052-controller-parsing.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C060-superclass-logic.md +95 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C067-no-hardcoded-config.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S003-sql-injection.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S004-no-log-credentials.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S005-server-authorization.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S006-default-credentials.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S007-output-encoding.md +96 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S009-approved-crypto.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S010-csprng.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S011-insecure-deserialization.md +74 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S012-secrets-management.md +81 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S013-tls-connections.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S017-parameterized-queries.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S019-session-management.md +131 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S020-kvc-injection.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S025-input-validation.md +125 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S029-brute-force-protection.md +120 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S036-path-traversal.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S039-tls-certificate-validation.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S041-logout-invalidation.md +103 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S043-password-hashing.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S044-critical-changes-reauth.md +145 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S045-debug-info-exposure.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S046-unvalidated-redirect.md +140 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S051-token-expiry.md +134 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S053-jwt-validation.md +139 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S059-background-snapshot-protection.md +113 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S060-data-protection-api.md +106 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S061-jailbreak-detection.md +132 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Lưu token và session credential trong Keychain - không dùng UserDefaults
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: UserDefaults và NSFileManager lưu plaintext trong app sandbox, có thể bị đọc trên jailbroken device, iTunes backup không mã hóa, hoặc ADB pull. Keychain mã hóa bằng Secure Enclave.
|
|
5
|
+
tags: swift, ios, keychain, userdefaults, session-management, token-storage, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Lưu token và session credential trong Keychain - không dùng UserDefaults
|
|
9
|
+
|
|
10
|
+
Access token, refresh token, user password, và session identifier phải được lưu trong **iOS Keychain** với accessibility phù hợp (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`). Không dùng `UserDefaults`, `NSFileManager`, hay bất kỳ file plaintext nào.
|
|
11
|
+
|
|
12
|
+
**Incorrect (lưu token trong UserDefaults):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
class AuthManager {
|
|
18
|
+
// !! UserDefaults - plaintext, backup leak, jailbreak accessible
|
|
19
|
+
func saveAccessToken(_ token: String) {
|
|
20
|
+
UserDefaults.standard.set(token, forKey: "access_token")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func getAccessToken() -> String? {
|
|
24
|
+
return UserDefaults.standard.string(forKey: "access_token")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// !! NSFileManager - file dễ bị đọc qua iTunes backup
|
|
28
|
+
func saveRefreshToken(_ token: String) {
|
|
29
|
+
let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
30
|
+
.appendingPathComponent("refresh.token")
|
|
31
|
+
try? token.write(to: path, atomically: true, encoding: .utf8)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Correct (Keychain với Secure Enclave):**
|
|
37
|
+
|
|
38
|
+
```swift
|
|
39
|
+
import Foundation
|
|
40
|
+
import Security
|
|
41
|
+
|
|
42
|
+
enum KeychainError: Error {
|
|
43
|
+
case duplicateEntry
|
|
44
|
+
case unknown(OSStatus)
|
|
45
|
+
case noData
|
|
46
|
+
case unexpectedData
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
struct KeychainService {
|
|
50
|
+
static let service = Bundle.main.bundleIdentifier ?? "com.app"
|
|
51
|
+
|
|
52
|
+
// SAFE: Lưu token vào Keychain
|
|
53
|
+
static func saveToken(_ token: String, key: String) throws {
|
|
54
|
+
guard let data = token.data(using: .utf8) else { return }
|
|
55
|
+
|
|
56
|
+
let query: [CFString: Any] = [
|
|
57
|
+
kSecClass: kSecClassGenericPassword,
|
|
58
|
+
kSecAttrService: service,
|
|
59
|
+
kSecAttrAccount: key,
|
|
60
|
+
kSecValueData: data,
|
|
61
|
+
// Chỉ accessible khi device unlocked, không sync iCloud, không backup
|
|
62
|
+
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
// Nếu đã tồn tại, update
|
|
66
|
+
let status = SecItemAdd(query as CFDictionary, nil)
|
|
67
|
+
if status == errSecDuplicateItem {
|
|
68
|
+
let updateQuery: [CFString: Any] = [
|
|
69
|
+
kSecClass: kSecClassGenericPassword,
|
|
70
|
+
kSecAttrService: service,
|
|
71
|
+
kSecAttrAccount: key
|
|
72
|
+
]
|
|
73
|
+
let updateAttributes: [CFString: Any] = [kSecValueData: data]
|
|
74
|
+
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
|
|
75
|
+
guard updateStatus == errSecSuccess else {
|
|
76
|
+
throw KeychainError.unknown(updateStatus)
|
|
77
|
+
}
|
|
78
|
+
} else if status != errSecSuccess {
|
|
79
|
+
throw KeychainError.unknown(status)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// SAFE: Đọc token từ Keychain
|
|
84
|
+
static func readToken(key: String) throws -> String {
|
|
85
|
+
let query: [CFString: Any] = [
|
|
86
|
+
kSecClass: kSecClassGenericPassword,
|
|
87
|
+
kSecAttrService: service,
|
|
88
|
+
kSecAttrAccount: key,
|
|
89
|
+
kSecReturnData: true,
|
|
90
|
+
kSecMatchLimit: kSecMatchLimitOne
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
var result: AnyObject?
|
|
94
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
95
|
+
guard status == errSecSuccess,
|
|
96
|
+
let data = result as? Data,
|
|
97
|
+
let token = String(data: data, encoding: .utf8) else {
|
|
98
|
+
throw KeychainError.noData
|
|
99
|
+
}
|
|
100
|
+
return token
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// SAFE: Xóa token khi logout
|
|
104
|
+
static func deleteToken(key: String) {
|
|
105
|
+
let query: [CFString: Any] = [
|
|
106
|
+
kSecClass: kSecClassGenericPassword,
|
|
107
|
+
kSecAttrService: service,
|
|
108
|
+
kSecAttrAccount: key
|
|
109
|
+
]
|
|
110
|
+
SecItemDelete(query as CFDictionary)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Usage
|
|
115
|
+
class AuthManager {
|
|
116
|
+
private let accessTokenKey = "com.app.accessToken"
|
|
117
|
+
private let refreshTokenKey = "com.app.refreshToken"
|
|
118
|
+
|
|
119
|
+
func saveTokens(accessToken: String, refreshToken: String) throws {
|
|
120
|
+
try KeychainService.saveToken(accessToken, key: accessTokenKey)
|
|
121
|
+
try KeychainService.saveToken(refreshToken, key: refreshTokenKey)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func clearSession() {
|
|
125
|
+
KeychainService.deleteToken(key: accessTokenKey)
|
|
126
|
+
KeychainService.deleteToken(key: refreshTokenKey)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Tools:** Keychain-Swift (library), OWASP MASVS-STORAGE-1, iMazing (backup inspection), objection (jailbreak inspection)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Không dùng value(forKeyPath:) với input người dùng - tránh KVC injection
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Truyền string người dùng vào value(forKeyPath:) hoặc NSPredicate(value:) dạng keyPath expression cho phép attacker truy cập private properties hoặc trigger unintended method calls.
|
|
5
|
+
tags: swift, ios, kvc, keypathinjection, nspredicate, dynamic-code, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Không dùng value(forKeyPath:) với input người dùng - tránh KVC injection
|
|
9
|
+
|
|
10
|
+
Key-Value Coding (KVC) trong Objective-C runtime cho phép truy cập bất kỳ property nào theo tên string. Truyền input người dùng vào `value(forKeyPath:)` hoặc dùng KVC trong NSPredicate format với keyPath không được validate là nguy hiểm.
|
|
11
|
+
|
|
12
|
+
**Incorrect (KVC với input người dùng):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
class ProfileViewController: UIViewController {
|
|
18
|
+
var user: User!
|
|
19
|
+
|
|
20
|
+
// !! KVC với field name từ user input - truy cập private properties!
|
|
21
|
+
func displayField(fieldName: String) {
|
|
22
|
+
// Nếu fieldName = "privateSSN" hoặc "password" → lộ dữ liệu nhạy cảm
|
|
23
|
+
let value = user.value(forKeyPath: fieldName)
|
|
24
|
+
print("Field value: \(value ?? "nil")")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// !! NSPredicate với dynamic keyPath từ server
|
|
28
|
+
func filterUsers(sortField: String, sortOrder: String, users: [User]) -> [User] {
|
|
29
|
+
// sortField từ server response không được validate
|
|
30
|
+
let predicate = NSPredicate(format: "%K == %@", sortField, "active")
|
|
31
|
+
return (users as NSArray).filtered(using: predicate) as? [User] ?? []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Correct (whitelist keyPath được phép):**
|
|
37
|
+
|
|
38
|
+
```swift
|
|
39
|
+
import Foundation
|
|
40
|
+
|
|
41
|
+
// SAFE: Whitelist các field được phép truy cập
|
|
42
|
+
enum UserDisplayField: String, CaseIterable {
|
|
43
|
+
case displayName = "displayName"
|
|
44
|
+
case email = "email"
|
|
45
|
+
case bio = "bio"
|
|
46
|
+
case avatarURL = "avatarURL"
|
|
47
|
+
// Không có: password, ssn, internalId, etc.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class ProfileViewController: UIViewController {
|
|
51
|
+
var user: User!
|
|
52
|
+
|
|
53
|
+
// SAFE: Validate field name trước khi dùng
|
|
54
|
+
func displayField(fieldName: String) {
|
|
55
|
+
guard let allowedField = UserDisplayField(rawValue: fieldName) else {
|
|
56
|
+
// Field không được phép - log và return
|
|
57
|
+
print("Warning: Attempted access to field '\(fieldName)'")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
let value = user.value(forKeyPath: allowedField.rawValue)
|
|
61
|
+
displayLabel.text = value as? String
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// SAFE: Enum-based sort column, không dùng raw string từ server
|
|
65
|
+
func filterActiveUsers(from users: [User]) -> [User] {
|
|
66
|
+
return users.filter { $0.status == .active }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// SAFE: Dùng Swift keypath thay vì KVC string
|
|
71
|
+
struct UserSorter {
|
|
72
|
+
enum SortField {
|
|
73
|
+
case name
|
|
74
|
+
case createdAt
|
|
75
|
+
case lastActive
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func sortUsers(_ users: [User], by field: SortField, ascending: Bool) -> [User] {
|
|
79
|
+
switch field {
|
|
80
|
+
case .name:
|
|
81
|
+
return users.sorted { ascending ? $0.name < $1.name : $0.name > $1.name }
|
|
82
|
+
case .createdAt:
|
|
83
|
+
return users.sorted { ascending ? $0.createdAt < $1.createdAt : $0.createdAt > $1.createdAt }
|
|
84
|
+
case .lastActive:
|
|
85
|
+
return users.sorted { ascending ? $0.lastActive < $1.lastActive : $0.lastActive > $1.lastActive }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Tools:** OWASP MASVS-CODE-4, SwiftLint custom rule (detect `value(forKeyPath:` with non-literal argument)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Validate toàn bộ input từ người dùng và URL scheme - không tin dữ liệu bên ngoài
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Không validate URL scheme parameters, deep link payload hoặc form input dẫn đến injection, logic bypass, hoặc truy cập unauthorized. Input từ bất kỳ source nào đều phải được validate ở server-side layer.
|
|
5
|
+
tags: swift, ios, input-validation, deeplink, url-scheme, universal-link, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Validate toàn bộ input từ người dùng và URL scheme - không tin dữ liệu bên ngoài
|
|
9
|
+
|
|
10
|
+
Mọi input từ người dùng, URL scheme, universal link, clipboard, hoặc push notification payload đều phải được validate: kiểm tra type, length, format và phạm vi cho phép trước khi xử lý. Đặc biệt cẩn thận với navigation target từ deep link.
|
|
11
|
+
|
|
12
|
+
**Incorrect (không validate deep link parameters):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import UIKit
|
|
16
|
+
|
|
17
|
+
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
18
|
+
|
|
19
|
+
// !! Không validate parameter từ deep link - open URL tùy ý
|
|
20
|
+
func application(_ app: UIApplication,
|
|
21
|
+
open url: URL,
|
|
22
|
+
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
|
23
|
+
guard url.scheme == "myapp" else { return false }
|
|
24
|
+
|
|
25
|
+
if url.host == "product" {
|
|
26
|
+
// !! productId có thể là "../../../etc" hoặc SQL injection
|
|
27
|
+
let productId = url.queryParameters["id"] ?? ""
|
|
28
|
+
navigateToProduct(id: productId) // Không validate!
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if url.host == "webview" {
|
|
32
|
+
// !! Mở URL tùy ý trong WKWebView - open redirect!
|
|
33
|
+
let targetURL = url.queryParameters["url"] ?? ""
|
|
34
|
+
openInWebView(urlString: targetURL) // Nguy hiểm!
|
|
35
|
+
}
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Correct (validate trước khi xử lý):**
|
|
42
|
+
|
|
43
|
+
```swift
|
|
44
|
+
import UIKit
|
|
45
|
+
|
|
46
|
+
// SAFE: Validator tập trung
|
|
47
|
+
enum ValidationError: LocalizedError {
|
|
48
|
+
case invalidFormat(String)
|
|
49
|
+
case outOfRange(String)
|
|
50
|
+
case suspiciousInput(String)
|
|
51
|
+
|
|
52
|
+
var errorDescription: String? {
|
|
53
|
+
switch self {
|
|
54
|
+
case .invalidFormat(let f): return "Invalid format: \(f)"
|
|
55
|
+
case .outOfRange(let f): return "Out of range: \(f)"
|
|
56
|
+
case .suspiciousInput(let f): return "Suspicious input: \(f)"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
struct InputValidator {
|
|
62
|
+
// Chỉ accept UUID format cho product ID
|
|
63
|
+
static func validateProductId(_ id: String) throws -> UUID {
|
|
64
|
+
guard let uuid = UUID(uuidString: id) else {
|
|
65
|
+
throw ValidationError.invalidFormat("productId must be UUID")
|
|
66
|
+
}
|
|
67
|
+
return uuid
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Chỉ accept HTTPS URL trong domain whitelist
|
|
71
|
+
static func validateInternalURL(_ urlString: String) throws -> URL {
|
|
72
|
+
guard let url = URL(string: urlString),
|
|
73
|
+
url.scheme == "https" else {
|
|
74
|
+
throw ValidationError.invalidFormat("URL must be HTTPS")
|
|
75
|
+
}
|
|
76
|
+
let allowedHosts = ["app.example.com", "static.example.com"]
|
|
77
|
+
guard let host = url.host, allowedHosts.contains(host) else {
|
|
78
|
+
throw ValidationError.suspiciousInput("URL host not in whitelist: \(url.host ?? "nil")")
|
|
79
|
+
}
|
|
80
|
+
return url
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static func validatePhoneNumber(_ number: String) throws -> String {
|
|
84
|
+
let regex = #"^\+?[0-9]{10,15}$"#
|
|
85
|
+
let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
|
|
86
|
+
guard predicate.evaluate(with: number) else {
|
|
87
|
+
throw ValidationError.invalidFormat("Invalid phone number")
|
|
88
|
+
}
|
|
89
|
+
return number
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
94
|
+
|
|
95
|
+
func application(_ app: UIApplication,
|
|
96
|
+
open url: URL,
|
|
97
|
+
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
|
98
|
+
guard url.scheme == "myapp" else { return false }
|
|
99
|
+
|
|
100
|
+
do {
|
|
101
|
+
if url.host == "product" {
|
|
102
|
+
let rawId = url.queryParameters["id"] ?? ""
|
|
103
|
+
let productId = try InputValidator.validateProductId(rawId)
|
|
104
|
+
navigateToProduct(id: productId) // UUID-typed, safe
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if url.host == "webview" {
|
|
108
|
+
let rawURL = url.queryParameters["url"] ?? ""
|
|
109
|
+
let safeURL = try InputValidator.validateInternalURL(rawURL)
|
|
110
|
+
openInWebView(url: safeURL) // Validated URL, whitelist
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Log attempt nhưng không crash
|
|
114
|
+
logger.warning("Invalid deep link parameter", metadata: [
|
|
115
|
+
"url": "\(url)",
|
|
116
|
+
"error": "\(error.localizedDescription)"
|
|
117
|
+
])
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Tools:** OWASP MASVS-PLATFORM-1, SwiftLint, URLComponents (safer URL parsing)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Implement lockout sau nhiều lần xác thực thất bại (PIN/password/biometrics)
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Không giới hạn số lần thử đăng nhập cho phép brute force tấn công PIN 4-6 chữ số trong vài giây. Lockout mechanism và exponential backoff là bắt buộc cho local auth.
|
|
5
|
+
tags: swift, ios, brute-force, lockout, pin, local-authentication, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Implement lockout sau nhiều lần xác thực thất bại (PIN/password/biometrics)
|
|
9
|
+
|
|
10
|
+
Với local authentication (PIN, passcode), phải implement lockout sau N lần thất bại với exponential backoff, và wipe data hoặc reroute về server auth sau threshold. Server API cũng cần rate limiting cho login attempts.
|
|
11
|
+
|
|
12
|
+
**Incorrect (không giới hạn số lần thử):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import LocalAuthentication
|
|
16
|
+
|
|
17
|
+
class PINViewController: UIViewController {
|
|
18
|
+
var enteredPIN = ""
|
|
19
|
+
|
|
20
|
+
// !! Không giới hạn số lần thử - brute force 0000-9999 trong vài giây
|
|
21
|
+
func validatePIN(_ pin: String) {
|
|
22
|
+
guard let storedPIN = UserDefaults.standard.string(forKey: "user_pin") else { return }
|
|
23
|
+
if pin == storedPIN {
|
|
24
|
+
navigateToHome()
|
|
25
|
+
} else {
|
|
26
|
+
showError("Wrong PIN")
|
|
27
|
+
// Không đếm số lần thất bại!
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Correct (lockout với exponential backoff):**
|
|
34
|
+
|
|
35
|
+
```swift
|
|
36
|
+
import LocalAuthentication
|
|
37
|
+
import Foundation
|
|
38
|
+
|
|
39
|
+
struct AuthAttemptTracker {
|
|
40
|
+
private let maxAttempts = 5
|
|
41
|
+
private let lockoutKey = "auth_lockout_until"
|
|
42
|
+
private let attemptCountKey = "auth_attempt_count"
|
|
43
|
+
private let keychain = KeychainService.self
|
|
44
|
+
|
|
45
|
+
var isLockedOut: Bool {
|
|
46
|
+
guard let lockoutDate = UserDefaults.standard.object(forKey: lockoutKey) as? Date else {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
return Date() < lockoutDate
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var remainingLockoutSeconds: TimeInterval {
|
|
53
|
+
guard let lockoutDate = UserDefaults.standard.object(forKey: lockoutKey) as? Date else {
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
return max(0, lockoutDate.timeIntervalSinceNow)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var failureCount: Int {
|
|
60
|
+
return UserDefaults.standard.integer(forKey: attemptCountKey)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
mutating func recordFailure() {
|
|
64
|
+
let count = failureCount + 1
|
|
65
|
+
UserDefaults.standard.set(count, forKey: attemptCountKey)
|
|
66
|
+
|
|
67
|
+
if count >= maxAttempts {
|
|
68
|
+
// Exponential backoff: 30s, 60s, 120s, 240s...
|
|
69
|
+
let lockoutDuration = TimeInterval(30 * pow(2.0, Double(count - maxAttempts)))
|
|
70
|
+
let lockoutUntil = Date().addingTimeInterval(min(lockoutDuration, 3600)) // Max 1h
|
|
71
|
+
UserDefaults.standard.set(lockoutUntil, forKey: lockoutKey)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func resetAfterSuccess() {
|
|
76
|
+
UserDefaults.standard.removeObject(forKey: attemptCountKey)
|
|
77
|
+
UserDefaults.standard.removeObject(forKey: lockoutKey)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class PINViewController: UIViewController {
|
|
82
|
+
private var tracker = AuthAttemptTracker()
|
|
83
|
+
|
|
84
|
+
// SAFE: Validate với lockout mechanism
|
|
85
|
+
func validatePIN(_ pin: String) {
|
|
86
|
+
guard !tracker.isLockedOut else {
|
|
87
|
+
let seconds = Int(tracker.remainingLockoutSeconds)
|
|
88
|
+
showError("Too many attempts. Try again in \(seconds)s")
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
do {
|
|
93
|
+
let storedPINHash = try KeychainService.readToken(key: "user_pin_hash")
|
|
94
|
+
// Compare hash, không phải plaintext
|
|
95
|
+
let inputHash = hashPIN(pin)
|
|
96
|
+
if inputHash == storedPINHash {
|
|
97
|
+
tracker.resetAfterSuccess()
|
|
98
|
+
navigateToHome()
|
|
99
|
+
} else {
|
|
100
|
+
tracker.recordFailure()
|
|
101
|
+
let remaining = 5 - tracker.failureCount
|
|
102
|
+
if remaining > 0 {
|
|
103
|
+
showError("Wrong PIN. \(remaining) attempts remaining.")
|
|
104
|
+
} else {
|
|
105
|
+
showError("Account locked. Use biometrics or contact support.")
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
showError("Authentication error")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func hashPIN(_ pin: String) -> String {
|
|
114
|
+
// Dùng CryptoKit với salt từ Keychain
|
|
115
|
+
return CryptoPINHasher.hash(pin: pin)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Tools:** OWASP MASVS-AUTH-2, OWASP Testing Guide (OTG-AUTHN-003), Instruments (brute force simulation)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Prevent path traversal khi xử lý file trong app sandbox
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Dùng filename từ user input hoặc server response để truy cập file mà không sanitize cho phép đọc file tùy ý trong sandbox, hoặc ghi đè file quan trọng như database và plist.
|
|
5
|
+
tags: swift, ios, path-traversal, file-access, sandbox, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Prevent path traversal khi xử lý file trong app sandbox
|
|
9
|
+
|
|
10
|
+
Khi tạo đường dẫn file từ input người dùng hoặc data từ server, phải validate path không chứa `../` và kết quả phải nằm trong thư mục cho phép. Luôn dùng `URL.appendingPathComponent` thay vì string concatenation cho path.
|
|
11
|
+
|
|
12
|
+
**Incorrect (path traversal qua tên file từ server):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
class FileDownloadManager {
|
|
18
|
+
let downloadsDir = FileManager.default.urls(
|
|
19
|
+
for: .documentDirectory, in: .userDomainMask
|
|
20
|
+
)[0].appendingPathComponent("downloads")
|
|
21
|
+
|
|
22
|
+
// !! filename từ server không được validate
|
|
23
|
+
func saveDownloadedFile(data: Data, filename: String) throws {
|
|
24
|
+
// Nếu filename = "../../Library/Application Support/default.realm" thì ghi đè DB!
|
|
25
|
+
let filePath = downloadsDir.path + "/" + filename // Path traversal!
|
|
26
|
+
FileManager.default.createFile(atPath: filePath, contents: data)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// !! Đọc file theo path từ server response
|
|
30
|
+
func readCachedFile(relativePath: String) -> Data? {
|
|
31
|
+
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
32
|
+
let fullPath = cacheDir.appendingPathComponent(relativePath) // Unsafe!
|
|
33
|
+
return try? Data(contentsOf: fullPath)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Correct (sanitize và validate path):**
|
|
39
|
+
|
|
40
|
+
```swift
|
|
41
|
+
import Foundation
|
|
42
|
+
|
|
43
|
+
enum FileStorageError: LocalizedError {
|
|
44
|
+
case pathTraversalDetected(String)
|
|
45
|
+
case fileOutsideAllowedDirectory(URL)
|
|
46
|
+
case invalidFilename(String)
|
|
47
|
+
|
|
48
|
+
var errorDescription: String? {
|
|
49
|
+
switch self {
|
|
50
|
+
case .pathTraversalDetected(let name): return "Path traversal detected: \(name)"
|
|
51
|
+
case .fileOutsideAllowedDirectory(let url): return "File outside allowed dir: \(url.path)"
|
|
52
|
+
case .invalidFilename(let name): return "Invalid filename: \(name)"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
struct SafeFileManager {
|
|
58
|
+
let baseDirectory: URL
|
|
59
|
+
|
|
60
|
+
// Validate filename: không có path separator, không có ".."
|
|
61
|
+
func sanitizeFilename(_ filename: String) throws -> String {
|
|
62
|
+
// Chỉ accept alphanumeric, dash, underscore, dot (không phải đầu)
|
|
63
|
+
let allowedPattern = #"^[a-zA-Z0-9][a-zA-Z0-9._\-]{0,254}$"#
|
|
64
|
+
let predicate = NSPredicate(format: "SELF MATCHES %@", allowedPattern)
|
|
65
|
+
guard predicate.evaluate(with: filename) else {
|
|
66
|
+
throw FileStorageError.invalidFilename(filename)
|
|
67
|
+
}
|
|
68
|
+
// Từ chối path separator và traversal
|
|
69
|
+
if filename.contains("/") || filename.contains("..") || filename.contains("\0") {
|
|
70
|
+
throw FileStorageError.pathTraversalDetected(filename)
|
|
71
|
+
}
|
|
72
|
+
return filename
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Verify resolved path nằm trong baseDirectory
|
|
76
|
+
func validatePathContainment(_ url: URL) throws -> URL {
|
|
77
|
+
let resolved = url.standardized
|
|
78
|
+
let base = baseDirectory.standardized
|
|
79
|
+
guard resolved.path.hasPrefix(base.path + "/") || resolved.path == base.path else {
|
|
80
|
+
throw FileStorageError.fileOutsideAllowedDirectory(resolved)
|
|
81
|
+
}
|
|
82
|
+
return resolved
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func saveFile(data: Data, filename: String) throws -> URL {
|
|
86
|
+
let safeName = try sanitizeFilename(filename)
|
|
87
|
+
let fileURL = baseDirectory.appendingPathComponent(safeName)
|
|
88
|
+
let validatedURL = try validatePathContainment(fileURL)
|
|
89
|
+
try data.write(to: validatedURL, options: [.atomic, .completeFileProtection])
|
|
90
|
+
return validatedURL
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func readFile(filename: String) throws -> Data {
|
|
94
|
+
let safeName = try sanitizeFilename(filename)
|
|
95
|
+
let fileURL = baseDirectory.appendingPathComponent(safeName)
|
|
96
|
+
let validatedURL = try validatePathContainment(fileURL)
|
|
97
|
+
return try Data(contentsOf: validatedURL)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Tools:** OWASP MASVS-STORAGE-3, SwiftLint custom rule (detect path + "/" + variable), Instruments
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Implement certificate pinning cho API quan trọng
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Không pin certificate cho phép attacker dùng proxy (Charles, Proxyman) hoặc cài root CA để đọc toàn bộ API traffic và đánh cắp token, dữ liệu người dùng.
|
|
5
|
+
tags: swift, ios, certificate-pinning, tls, ssl-pinning, urlsession, alamofire, security
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Implement certificate pinning cho API quan trọng
|
|
9
|
+
|
|
10
|
+
Certificate pinning đảm bảo app chỉ chấp nhận certificate của server đã biết trước, ngăn chặn MITM attack ngay cả khi attacker cài root CA. Implement bằng `URLSessionDelegate` hoặc Alamofire `ServerTrustManager`.
|
|
11
|
+
|
|
12
|
+
**Incorrect (không pin certificate - chấp nhận mọi certificate):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
// !! Không pin - chấp nhận certificate bất kỳ trust chain nào phát hành
|
|
18
|
+
class InsecureNetworkManager: NSObject {
|
|
19
|
+
lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
|
20
|
+
|
|
21
|
+
func fetchUserData(token: String) {
|
|
22
|
+
var request = URLRequest(url: URL(string: "https://api.example.com/user")!)
|
|
23
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
24
|
+
session.dataTask(with: request).resume()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
extension InsecureNetworkManager: URLSessionDelegate {
|
|
29
|
+
// !! Không implement - mặc định không pin
|
|
30
|
+
// Proxy như Charles/Proxyman đọc được hết traffic
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// !! Hoặc tệ hơn: disable validation hoàn toàn (chỉ thấy trong dev code nhưng sót production)
|
|
34
|
+
extension InsecureNetworkManager: URLSessionDelegate {
|
|
35
|
+
func urlSession(_ session: URLSession,
|
|
36
|
+
didReceive challenge: URLAuthenticationChallenge,
|
|
37
|
+
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
|
38
|
+
// !! CHỚ BAO GIỜ: accept mọi certificate
|
|
39
|
+
completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Correct (certificate pinning với public key hash):**
|
|
45
|
+
|
|
46
|
+
```swift
|
|
47
|
+
import Foundation
|
|
48
|
+
import CryptoKit
|
|
49
|
+
|
|
50
|
+
class PinnedNetworkManager: NSObject {
|
|
51
|
+
// SAFE: Hardcode SHA256 hash của public key server certificate
|
|
52
|
+
// Nên pin ít nhất 2 hashes (primary + backup) để rollover không gây outage
|
|
53
|
+
private let pinnedKeyHashes: Set<String> = [
|
|
54
|
+
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // Primary cert
|
|
55
|
+
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=" // Backup cert
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
lazy var session: URLSession = {
|
|
59
|
+
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
|
60
|
+
}()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
extension PinnedNetworkManager: URLSessionDelegate {
|
|
64
|
+
func urlSession(_ session: URLSession,
|
|
65
|
+
didReceive challenge: URLAuthenticationChallenge,
|
|
66
|
+
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
|
67
|
+
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
|
68
|
+
let serverTrust = challenge.protectionSpace.serverTrust else {
|
|
69
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Evaluate server trust
|
|
74
|
+
var error: CFError?
|
|
75
|
+
guard SecTrustEvaluateWithError(serverTrust, &error) else {
|
|
76
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Lấy certificate chain và kiểm tra public key hash
|
|
81
|
+
let certificateCount = SecTrustGetCertificateCount(serverTrust)
|
|
82
|
+
for index in 0..<certificateCount {
|
|
83
|
+
guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, index) else { continue }
|
|
84
|
+
let publicKey = SecCertificateCopyKey(certificate)
|
|
85
|
+
guard let keyData = publicKey.flatMap({ SecKeyCopyExternalRepresentation($0, nil) as Data? }) else { continue }
|
|
86
|
+
|
|
87
|
+
let hash = SHA256.hash(data: keyData)
|
|
88
|
+
let hashBase64 = Data(hash).base64EncodedString()
|
|
89
|
+
|
|
90
|
+
if pinnedKeyHashes.contains(hashBase64) {
|
|
91
|
+
completionHandler(.useCredential, URLCredential(trust: serverTrust))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Không match pin nào → reject
|
|
97
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// SAFE: Với Alamofire, dùng ServerTrustManager
|
|
102
|
+
// import Alamofire
|
|
103
|
+
// let evaluators: [String: ServerTrustEvaluating] = [
|
|
104
|
+
// "api.example.com": PublicKeysTrustEvaluator()
|
|
105
|
+
// ]
|
|
106
|
+
// let session = Session(serverTrustManager: ServerTrustManager(evaluators: evaluators))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Tools:** TrustKit (library), Alamofire ServerTrustManager, OWASP MASVS-NETWORK-2, SSL Labs, Proxyman (test pinning)
|