fix(security): harden extension bridge and persistence

This commit is contained in:
nimbold
2026-06-04 03:19:57 +03:30
parent e948590335
commit 91cd65e1d7
6 changed files with 222 additions and 41 deletions
+6 -3
View File
@@ -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" cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
echo "Packaging Firefox extension..." echo "Packaging Firefox extension..."
cp -r "$ROOT_DIR/Extensions/Firefox" "$RESOURCES_DIR/FirefoxExtension" mkdir -p "$RESOURCES_DIR/FirefoxExtension"
# Also remove .DS_Store if any cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
find "$RESOURCES_DIR/FirefoxExtension" -name ".DS_Store" -delete 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) ARIA2C_PATH=$(which aria2c || true)
+8 -2
View File
@@ -676,7 +676,7 @@ final class DownloadController: ObservableObject {
private func saveDownloads() { private func saveDownloads() {
let queuesCopy = queues let queuesCopy = queues
let downloadsCopy = downloads let downloadsCopy = downloads.map(\.redactedForPersistence)
let storageURL = self.storageURL let storageURL = self.storageURL
saveTask?.cancel() saveTask?.cancel()
@@ -713,9 +713,15 @@ final class DownloadController: ObservableObject {
} }
var shouldResumeRecoveredDownloads = false var shouldResumeRecoveredDownloads = false
var shouldRewriteStoredDownloads = isLegacyDownloadList
self.queues = normalizedQueues(state.queues) self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in self.downloads = state.downloads.map { item in
var adjusted = item var adjusted = item
let redacted = adjusted.redactedForPersistence
if redacted != adjusted {
adjusted = redacted
shouldRewriteStoredDownloads = true
}
adjusted.queueID = validQueueID(adjusted.queueID) adjusted.queueID = validQueueID(adjusted.queueID)
if isLegacyDownloadList, item.queueID == nil { if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID adjusted.queueID = DownloadQueue.mainQueueID
@@ -735,7 +741,7 @@ final class DownloadController: ObservableObject {
return adjusted return adjusted
} }
if shouldResumeRecoveredDownloads { if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads {
saveDownloads() saveDownloads()
} }
return shouldResumeRecoveredDownloads return shouldResumeRecoveredDownloads
+187 -34
View File
@@ -3,18 +3,24 @@ import Network
import AppKit import AppKit
final class LocalExtensionServer: @unchecked Sendable { 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 listener: NWListener
private let downloadController: DownloadController private let downloadController: DownloadController
private let queue = DispatchQueue(label: "local.firelink.server") private let queue = DispatchQueue(label: "local.firelink.server")
init?(downloadController: DownloadController) { init?(downloadController: DownloadController) {
self.downloadController = downloadController self.downloadController = downloadController
let port = NWEndpoint.Port(rawValue: 6412)!
let parameters = NWParameters.tcp let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: Constants.port)
do { do {
listener = try NWListener(using: parameters, on: port) listener = try NWListener(using: parameters)
} catch { } catch {
print("Failed to create listener: \(error)") print("Failed to create listener: \(error)")
return nil return nil
@@ -33,51 +39,198 @@ final class LocalExtensionServer: @unchecked Sendable {
private func handleConnection(_ connection: NWConnection) { private func handleConnection(_ connection: NWConnection) {
connection.start(queue: queue) 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 connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
if let data = data, let requestString = String(data: data, encoding: .utf8) { guard let self else {
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
connection.cancel() 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) { private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
guard let range = request.range(of: "\r\n\r\n") else { return } var headers = [
"HTTP/1.1 \(status.rawValue) \(status.reason)",
let bodyString = request[range.upperBound...] "Content-Length: 0",
guard let data = bodyString.data(using: .utf8) else { return } "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 { struct Payload: Decodable {
let urls: [String] let urls: [String]
let referer: String? let referer: String?
} }
do { do {
let payload = try JSONDecoder().decode(Payload.self, from: data) let payload = try JSONDecoder().decode(Payload.self, from: request.body)
Task { @MainActor in let validURLs = payload.urls
let text = payload.urls.joined(separator: "\n") .prefix(Constants.maxURLCount)
if !text.isEmpty { .compactMap { rawURL -> String? in
self.downloadController.pendingPasteboardText = text let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) guard let url = URL(string: trimmed),
NSApp.activate(ignoringOtherApps: true) 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 { } catch {
print("Failed to parse local request JSON: \(error)") 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[..<headerRange.lowerBound]
guard let headerString = String(data: headerData, encoding: .utf8) else {
return nil
}
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
guard let requestLine = lines.first else {
return nil
}
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
guard requestParts.count >= 2 else {
return nil
}
var parsedHeaders: [String: String] = [:]
for line in lines.dropFirst() {
guard let colonIndex = line.firstIndex(of: ":") else {
continue
}
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
parsedHeaders[name] = value
}
let bodyStart = headerRange.upperBound
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
guard expectedBodyLength >= 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"
} }
} }
} }
+19
View File
@@ -206,6 +206,25 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
} }
return "\(speedLimitKiBPerSecond) KiB/s" 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 { struct DownloadProgress: Equatable, Sendable {
+1 -1
View File
@@ -712,7 +712,7 @@ private struct IntegrationSettingsPane: View {
} }
Section("Permissions & Privacy") { 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }