mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 08:26:40 +00:00
fix(security): harden extension bridge and persistence
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: fcb619e73c...2f99dda366
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user