From 91cd65e1d7cdcd57017252e622d025d97b5387d9 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:19:57 +0330 Subject: [PATCH] fix(security): harden extension bridge and persistence --- Extensions/Firefox | 2 +- Scripts/create_app_bundle.sh | 9 +- Sources/Firelink/DownloadController.swift | 10 +- Sources/Firelink/LocalExtensionServer.swift | 221 +++++++++++++++++--- Sources/Firelink/Models.swift | 19 ++ Sources/Firelink/SettingsView.swift | 2 +- 6 files changed, 222 insertions(+), 41 deletions(-) diff --git a/Extensions/Firefox b/Extensions/Firefox index fcb619e..2f99dda 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit fcb619e73ced0d97194a148c26df593a1d3c6903 +Subproject commit 2f99dda36676a6c74ec1eb908c4499530af683f5 diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index 7d85e31..2ae4bfb 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -22,9 +22,12 @@ cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns" cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png" echo "Packaging Firefox extension..." -cp -r "$ROOT_DIR/Extensions/Firefox" "$RESOURCES_DIR/FirefoxExtension" -# Also remove .DS_Store if any -find "$RESOURCES_DIR/FirefoxExtension" -name ".DS_Store" -delete +mkdir -p "$RESOURCES_DIR/FirefoxExtension" +cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js" +cp "$ROOT_DIR/Extensions/Firefox/content.js" "$RESOURCES_DIR/FirefoxExtension/content.js" +cp "$ROOT_DIR/Extensions/Firefox/manifest.json" "$RESOURCES_DIR/FirefoxExtension/manifest.json" +cp -R "$ROOT_DIR/Extensions/Firefox/icons" "$RESOURCES_DIR/FirefoxExtension/icons" +cp -R "$ROOT_DIR/Extensions/Firefox/popup" "$RESOURCES_DIR/FirefoxExtension/popup" ARIA2C_PATH=$(which aria2c || true) diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index fc2560e..af58f54 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -676,7 +676,7 @@ final class DownloadController: ObservableObject { private func saveDownloads() { let queuesCopy = queues - let downloadsCopy = downloads + let downloadsCopy = downloads.map(\.redactedForPersistence) let storageURL = self.storageURL saveTask?.cancel() @@ -713,9 +713,15 @@ final class DownloadController: ObservableObject { } var shouldResumeRecoveredDownloads = false + var shouldRewriteStoredDownloads = isLegacyDownloadList self.queues = normalizedQueues(state.queues) self.downloads = state.downloads.map { item in var adjusted = item + let redacted = adjusted.redactedForPersistence + if redacted != adjusted { + adjusted = redacted + shouldRewriteStoredDownloads = true + } adjusted.queueID = validQueueID(adjusted.queueID) if isLegacyDownloadList, item.queueID == nil { adjusted.queueID = DownloadQueue.mainQueueID @@ -735,7 +741,7 @@ final class DownloadController: ObservableObject { return adjusted } - if shouldResumeRecoveredDownloads { + if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads { saveDownloads() } return shouldResumeRecoveredDownloads diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index a9e2208..341c10e 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -3,18 +3,24 @@ import Network import AppKit final class LocalExtensionServer: @unchecked Sendable { + private enum Constants { + static let port = NWEndpoint.Port(rawValue: 6412)! + static let maxRequestBytes = 128 * 1024 + static let maxURLCount = 200 + static let allowedSchemes = Set(["http", "https", "ftp", "sftp"]) + } + private let listener: NWListener private let downloadController: DownloadController private let queue = DispatchQueue(label: "local.firelink.server") init?(downloadController: DownloadController) { self.downloadController = downloadController - - let port = NWEndpoint.Port(rawValue: 6412)! let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: Constants.port) do { - listener = try NWListener(using: parameters, on: port) + listener = try NWListener(using: parameters) } catch { print("Failed to create listener: \(error)") return nil @@ -33,51 +39,198 @@ final class LocalExtensionServer: @unchecked Sendable { private func handleConnection(_ connection: NWConnection) { connection.start(queue: queue) - + receiveRequest(from: connection, accumulatedData: Data()) + } + + private func receiveRequest(from connection: NWConnection, accumulatedData: Data) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in - if let data = data, let requestString = String(data: data, encoding: .utf8) { - self?.processRequest(requestString) - } - - let response = """ - HTTP/1.1 200 OK\r - Access-Control-Allow-Origin: *\r - Access-Control-Allow-Methods: POST, OPTIONS\r - Access-Control-Allow-Headers: Content-Type\r - Content-Length: 0\r - Connection: close\r - \r\n - """ - - connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in + guard let self else { connection.cancel() - }) + return + } + + var requestData = accumulatedData + if let data { + requestData.append(data) + } + + guard error == nil, requestData.count <= Constants.maxRequestBytes else { + self.sendResponse(.payloadTooLarge, connection: connection, origin: nil) + return + } + + if let request = HTTPRequest(data: requestData) { + let status = self.processRequest(request) + self.sendResponse(status, connection: connection, origin: request.header(named: "origin")) + return + } + + if isComplete { + self.sendResponse(.badRequest, connection: connection, origin: nil) + return + } + + self.receiveRequest(from: connection, accumulatedData: requestData) } } - private func processRequest(_ request: String) { - guard let range = request.range(of: "\r\n\r\n") else { return } - - let bodyString = request[range.upperBound...] - guard let data = bodyString.data(using: .utf8) else { return } - + private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) { + var headers = [ + "HTTP/1.1 \(status.rawValue) \(status.reason)", + "Content-Length: 0", + "Connection: close" + ] + + if let origin, isAllowedExtensionOrigin(origin) { + headers.append("Access-Control-Allow-Origin: \(origin)") + headers.append("Vary: Origin") + headers.append("Access-Control-Allow-Methods: POST, OPTIONS") + headers.append("Access-Control-Allow-Headers: Content-Type") + } + + let response = headers.joined(separator: "\r\n") + "\r\n\r\n" + connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private func isAllowedExtensionOrigin(_ origin: String) -> Bool { + guard let url = URL(string: origin), + let scheme = url.scheme?.lowercased() else { + return false + } + + return scheme == "moz-extension" || scheme == "chrome-extension" + } + + private func processRequest(_ request: HTTPRequest) -> HTTPStatus { + guard request.path == "/download" else { + return .notFound + } + + if request.method == "OPTIONS" { + return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden + } + + guard request.method == "POST" else { + return .methodNotAllowed + } + + guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else { + return .unsupportedMediaType + } + struct Payload: Decodable { let urls: [String] let referer: String? } - + do { - let payload = try JSONDecoder().decode(Payload.self, from: data) - Task { @MainActor in - let text = payload.urls.joined(separator: "\n") - if !text.isEmpty { - self.downloadController.pendingPasteboardText = text - NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) - NSApp.activate(ignoringOtherApps: true) + let payload = try JSONDecoder().decode(Payload.self, from: request.body) + let validURLs = payload.urls + .prefix(Constants.maxURLCount) + .compactMap { rawURL -> String? in + let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + Constants.allowedSchemes.contains(scheme) else { + return nil + } + return url.absoluteString } + + guard !validURLs.isEmpty else { + return .badRequest } + + Task { @MainActor in + self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n") + NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) + NSApp.activate(ignoringOtherApps: true) + } + + return .ok } catch { print("Failed to parse local request JSON: \(error)") + return .badRequest + } + } +} + +private struct HTTPRequest { + var method: String + var path: String + var headers: [String: String] + var body: Data + + init?(data: Data) { + guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else { + return nil + } + + let headerData = data[..= 2 else { + return nil + } + + var parsedHeaders: [String: String] = [:] + for line in lines.dropFirst() { + guard let colonIndex = line.firstIndex(of: ":") else { + continue + } + let name = line[..= 0, + data.count >= bodyStart + expectedBodyLength else { + return nil + } + + method = String(requestParts[0]).uppercased() + path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? "" + headers = parsedHeaders + body = data[bodyStart..<(bodyStart + expectedBodyLength)] + } + + func header(named name: String) -> String? { + headers[name.lowercased()] + } +} + +private enum HTTPStatus: Int { + case ok = 200 + case noContent = 204 + case badRequest = 400 + case forbidden = 403 + case notFound = 404 + case methodNotAllowed = 405 + case payloadTooLarge = 413 + case unsupportedMediaType = 415 + + var reason: String { + switch self { + case .ok: "OK" + case .noContent: "No Content" + case .badRequest: "Bad Request" + case .forbidden: "Forbidden" + case .notFound: "Not Found" + case .methodNotAllowed: "Method Not Allowed" + case .payloadTooLarge: "Payload Too Large" + case .unsupportedMediaType: "Unsupported Media Type" } } } diff --git a/Sources/Firelink/Models.swift b/Sources/Firelink/Models.swift index 8d0cecf..379bbe7 100644 --- a/Sources/Firelink/Models.swift +++ b/Sources/Firelink/Models.swift @@ -206,6 +206,25 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable { } return "\(speedLimitKiBPerSecond) KiB/s" } + + var redactedForPersistence: DownloadItem { + var item = self + item.credentials = nil + item.cookieHeader = nil + item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue } + return item + } +} + +private extension DownloadRequestHeader { + var containsSensitiveValue: Bool { + switch normalized.name.lowercased() { + case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token": + true + default: + false + } + } } struct DownloadProgress: Equatable, Sendable { diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index 9a11c5f..4cff544 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -712,7 +712,7 @@ private struct IntegrationSettingsPane: View { } Section("Permissions & Privacy") { - Text("The Firelink extension requests minimal permissions. It only reads your current tab when you explicitly click 'Download with Firelink' from the right-click menu, keeping your browsing history completely private.") + Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.") .font(.caption) .foregroundStyle(.secondary) }