Files
缘生 6a7aeffb83 fix(security): 修复辅助工具鉴权、升级路径与卸载误删 (#1)
* fix(security): 修复辅助工具鉴权降级与五处正确性缺陷

代码审查(安全/质量/性能/架构四维度)发现的问题,逐条修复。

辅助工具鉴权(High)
  ad-hoc 构建下 clientRequirement 退化为只校验 bundle identifier,不含
  anchor 约束,任何进程 codesign -s - 伪造同名即可连上 root 辅助工具,
  驱动 DNS、睡眠、本地快照与风扇。project.yml 默认就是 ad-hoc 签名,
  而 make build 是文档化的安装路径,开发机上属常态。
  改为无团队签名时返回 nil 并拒绝连接,与在线升级在 ad-hoc 下直接禁用
  的策略一致;此时 App 走一次性管理员授权回退,功能不丢。

卸载器批量误删
  validate 只拒绝 com.apple. 前缀,包名为 "com" 的应用可通过;随后
  hasPrefix("com.") 把 leftovers 扫描的十余个目录(Preferences、Caches、
  Containers、Saved Application State 等)下所有 com.* 条目当成残留列出
  并默认勾选,一次点击即整体移入废纸篓。前缀匹配改为要求至少三段反向
  域名,精确匹配不受影响。

退出时静默跳过恢复睡眠
  restoreDefaultsSynchronously 的 guard 在循环体内用 return 而非 continue,
  第一步取代理失败就连带跳过第二步,Mac 保持禁止睡眠。同时把两步各等 1 秒
  改为总预算 1.5 秒,避免主线程退出卡顿。

公网 IP 查询重入保护失效
  isLookingUpPublic 同时承担重入锁与转圈开关两个相反职责,有缓存且非强制
  刷新时恒为 false,guard 形同虚设,两个 Task 可各发三次请求并发写缓存,
  失败结果可能覆盖好结果。拆出独立的 isFetchingPublic,并用 defer 确保
  所有退出路径复位。

诊断导出的崩溃报告未脱敏
  日志与清理记录都走了 redact,但 .ips 崩溃报告原样入包,其中可能夹带
  进程内存中的 WebDAV 目录、用户名、公网 IP 与主机名,与界面“不含 IP、
  序列号与硬件地址”的声明不符。改为同样脱敏后写入。

PowerSampler
  channels(in:) 的 unsafeBitCast 改为 Unmanaged.fromOpaque.takeUnretainedValue。
  未加 deinit 释放 IOReport 订阅:IOReport 是私有框架,无公开释放入口且
  无法验证其是否为 CF 对象,而全进程只有一个 PowerSampler、订阅各只创建
  一次且随进程存在,不是增长型泄漏——已就此写明注释。

补回归测试:clientRequirement 的 ad-hoc 分支与 anchor 约束、
matchesIdentifier 的过宽前缀与派生名匹配。

make test 141 项通过(新增 4 项),Release 完整构建通过。

* fix(security): 完善辅助工具升级与卸载边界校验

* fix(ui): 拆分评分条视图以解决 CI 类型检查超时

* fix(webdav): 规范化目录路径以兼容 macOS 15
2026-09-21 09:34:01 +08:00

184 lines
9.3 KiB
Swift

// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import CryptoKit
import Foundation
import Localization
public enum WebDAVError: Error, LocalizedError, Equatable, Sendable {
case invalidAddress, invalidUsername, missingPassword, notConfigured
case unauthorized, forbidden, notFound, directoryMissing, locked, insufficientStorage
case redirect, tooLarge, invalidDocument, unsupportedVersion, transport
case http(Int)
public var errorDescription: String? {
switch self {
case .invalidAddress: tr("请输入有效的 HTTPS WebDAV 目录地址,不要包含账号、查询参数或片段")
case .invalidUsername: tr("请输入 WebDAV 用户名,不能包含冒号或控制字符")
case .missingPassword: tr("请输入 WebDAV 密码或应用专用密码")
case .notConfigured: tr("请先保存 WebDAV 配置")
case .unauthorized: tr("WebDAV 认证失败,请检查用户名和密码")
case .forbidden: tr("WebDAV 拒绝访问,请检查目录读写权限")
case .notFound: tr("远端尚无设置文件,请先上传本机设置")
case .directoryMissing: tr("WebDAV 目录不存在,请先在服务器上创建该目录")
case .locked: tr("远端文件已锁定,请稍后重试")
case .insufficientStorage: tr("WebDAV 存储空间不足")
case .redirect: tr("WebDAV 地址发生重定向,请填写最终目录地址")
case .tooLarge: tr("设置文件超过 1 MB,已停止同步")
case .invalidDocument: tr("远端文件不是有效的 XStats 设置备份,本机设置未更改")
case .unsupportedVersion: tr("设置备份版本不受支持,请先更新 XStats")
case .transport: tr("无法连接 WebDAV,请检查网络、地址和 HTTPS 证书")
case .http(let status): tr("WebDAV 返回错误:\(status)")
}
}
}
/// 只保存非敏感连接信息。目录必须已存在,固定文件名避免用户误覆盖其他文件。
public struct WebDAVConfiguration: Equatable, Sendable {
public let directoryURL: URL
public let username: String
public var fileURL: URL { directoryURL.appendingPathComponent("xstats-settings.json") }
public init(address: String, username: String) throws {
let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard let components = URLComponents(string: text),
components.scheme?.lowercased() == "https",
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil,
components.query == nil, components.fragment == nil,
!text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }),
!components.path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }),
components.url != nil else { throw WebDAVError.invalidAddress }
guard !username.isEmpty, !username.contains(":"),
!username.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
else { throw WebDAVError.invalidUsername }
// macOS 15 对空 path component 的目录拼接可能产生双斜杠;显式保留一个尾斜杠,
// 并操作编码后的路径,避免中文或百分号被再次转义而改变端点/凭据键。
var directory = components
var path = directory.percentEncodedPath
while path.hasSuffix("/") { path.removeLast() }
directory.percentEncodedPath = path + "/"
guard let normalized = directory.url else { throw WebDAVError.invalidAddress }
directoryURL = normalized
self.username = username
}
/// 密码按端点和用户隔离,更换服务器时不会向新服务器发送旧密码。
public var credentialAccount: String {
SHA256.hash(data: Data("\(fileURL.absoluteString)\n\(username)".utf8))
.map { String(format: "%02x", $0) }.joined()
}
}
public struct WebDAVResponse: Sendable {
public let data: Data
public let status: Int
public init(data: Data, status: Int) { self.data = data; self.status = status }
}
public protocol WebDAVTransport: Sendable {
func send(_ request: URLRequest) async throws -> WebDAVResponse
}
/// 独立的临时会话不共享 Cookie 或凭据;流式读取限制内存占用,并拒绝带认证信息重定向。
public struct URLSessionWebDAVTransport: WebDAVTransport {
private let makeConfiguration: @Sendable () -> URLSessionConfiguration
public init(configuration: @escaping @Sendable () -> URLSessionConfiguration = { .ephemeral }) {
makeConfiguration = configuration
}
public func send(_ request: URLRequest) async throws -> WebDAVResponse {
let configuration = makeConfiguration()
configuration.urlCache = nil
configuration.httpCookieStorage = nil
configuration.urlCredentialStorage = nil
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let (bytes, response) = try await session.bytes(for: request, delegate: RejectRedirects())
guard let http = response as? HTTPURLResponse else { throw WebDAVError.transport }
// 非 GET 成功响应无需读取内容,避免错误 HTML 或 PUT 响应体影响结果。
guard request.httpMethod == "GET", http.statusCode == 200 else {
return WebDAVResponse(data: Data(), status: http.statusCode)
}
guard response.expectedContentLength <= Int64(WebDAVClient.maximumBytes) else { throw WebDAVError.tooLarge }
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < WebDAVClient.maximumBytes else { throw WebDAVError.tooLarge }
data.append(byte)
}
return WebDAVResponse(data: data, status: http.statusCode)
}
}
/// 一律拒绝重定向,避免 Basic 密码泄漏或 PUT 被服务端重定向改成 GET。
final class RejectRedirects: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest) async -> URLRequest? { nil }
}
/// GET 下载、PUT 整份上传;用户在界面确认覆盖,不做隐式后台合并或自动重试上传。
public struct WebDAVClient: Sendable {
public static let maximumBytes = 1_048_576
private let transport: any WebDAVTransport
public init(transport: any WebDAVTransport = URLSessionWebDAVTransport()) { self.transport = transport }
public func download(configuration: WebDAVConfiguration, password: String) async throws -> Data {
let response = try await perform("GET", configuration: configuration, password: password)
guard response.status == 200 else { throw failure(response.status, uploading: false) }
guard response.data.count <= Self.maximumBytes else { throw WebDAVError.tooLarge }
return response.data
}
public func upload(_ data: Data, configuration: WebDAVConfiguration, password: String) async throws {
guard data.count <= Self.maximumBytes else { throw WebDAVError.tooLarge }
let response = try await perform("PUT", configuration: configuration, password: password, data: data)
guard [200, 201, 204].contains(response.status) else { throw failure(response.status, uploading: true) }
}
private func perform(_ method: String, configuration: WebDAVConfiguration, password: String,
data: Data? = nil) async throws -> WebDAVResponse {
try Task.checkCancellation()
guard !password.isEmpty else { throw WebDAVError.missingPassword }
var request = URLRequest(url: configuration.fileURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
request.httpMethod = method
request.setValue("Basic " + Data("\(configuration.username):\(password)".utf8).base64EncodedString(),
forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("XStats", forHTTPHeaderField: "User-Agent")
if let data {
request.httpBody = data
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
}
do {
let response = try await transport.send(request)
try Task.checkCancellation()
return response
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
// 不展示服务端原始错误体或 URL 错误信息,防止返回内容包含凭据。
throw error as? WebDAVError ?? WebDAVError.transport
}
}
private func failure(_ status: Int, uploading: Bool) -> WebDAVError {
switch status {
case 301...399: .redirect
case 401: .unauthorized
case 403: .forbidden
case 404: uploading ? .directoryMissing : .notFound
case 409: .directoryMissing
case 413: .tooLarge
case 423: .locked
case 507: .insufficientStorage
default: .http(status)
}
}
}