feat: enforce dynamic browser extension pairing security

- Generate random pairing token in AppSettings

- Update LocalExtensionServer to strict check token on non-OPTIONS endpoints

- Add GET /ping endpoint for extension connection verification

- Redesign Integration settings pane with modern step-by-step UI and Toast notifications

- Harden media extraction and aria2 engine paths

- Update IP resolution with getaddrinfo
This commit is contained in:
nimbold
2026-06-09 06:30:39 +03:30
parent 109059e10c
commit f887c62195
9 changed files with 301 additions and 86 deletions
+9 -1
View File
@@ -183,6 +183,10 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var extensionPairingToken: String {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
@@ -204,6 +208,7 @@ final class AppSettings: ObservableObject {
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
mediaCookieSource = stored.mediaCookieSource ?? .none
extensionPairingToken = stored.extensionPairingToken ?? UUID().uuidString
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
appTheme = .system
@@ -216,6 +221,7 @@ final class AppSettings: ObservableObject {
proxySettings = ProxySettings()
siteLogins = []
mediaCookieSource = .none
extensionPairingToken = UUID().uuidString
downloadDirectories = Self.defaultDirectories()
}
@@ -332,7 +338,8 @@ final class AppSettings: ObservableObject {
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins,
mediaCookieSource: mediaCookieSource
mediaCookieSource: mediaCookieSource,
extensionPairingToken: extensionPairingToken
)
let defaults = self.defaults
let storageKey = self.storageKey
@@ -404,4 +411,5 @@ private struct StoredSettings: Codable {
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
var mediaCookieSource: BrowserCookieSource?
var extensionPairingToken: String?
}
+2 -9
View File
@@ -56,21 +56,14 @@ final class Aria2DownloadEngine {
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
"/usr/bin/aria2c"
"/usr/bin/aria2c",
"/opt/local/bin/aria2c"
]
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return URL(fileURLWithPath: found)
}
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
for folder in path.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c")
if FileManager.default.isExecutableFile(atPath: candidate.path) {
return candidate
}
}
return nil
}
+72 -15
View File
@@ -134,23 +134,80 @@ enum DownloadMetadataFetcher {
if h == "localhost" || h.hasSuffix(".local") { return true }
if !h.contains(".") && !h.contains(":") { return true }
let parts = h.split(separator: ".")
if parts.count == 4, let first = Int(parts[0]), let second = Int(parts[1]) {
if first == 127 || first == 10 || (first == 192 && second == 168) {
return true
}
if first == 172 && (16...31).contains(second) {
return true
}
if first == 169 && second == 254 {
return true
}
}
var hints = addrinfo(
ai_flags: 0,
ai_family: AF_UNSPEC,
ai_socktype: SOCK_STREAM,
ai_protocol: 0,
ai_addrlen: 0,
ai_canonname: nil,
ai_addr: nil,
ai_next: nil
)
if h.contains(":") {
if h == "[::1]" || h.hasPrefix("[fc") || h.hasPrefix("[fd") || h.hasPrefix("[fe8") || h.hasPrefix("[fe9") || h.hasPrefix("[fea") || h.hasPrefix("[feb") {
return true
var res: UnsafeMutablePointer<addrinfo>?
if getaddrinfo(host, nil, &hints, &res) == 0 {
var current = res
while let info = current {
let family = info.pointee.ai_family
if family == AF_INET {
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
let first = (ip >> 24) & 0xFF
let second = (ip >> 16) & 0xFF
if first == 127 || first == 10 || (first == 192 && second == 168) {
freeaddrinfo(res)
return true
}
if first == 172 && (16...31).contains(second) {
freeaddrinfo(res)
return true
}
if first == 169 && second == 254 {
freeaddrinfo(res)
return true
}
} else if family == AF_INET6 {
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
let isULA = (bytes.0 & 0xFE) == 0xFC
let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
if isLoopback || isULA || isLinkLocal {
freeaddrinfo(res)
return true
}
if isIPv4Mapped {
let first = bytes.12
let second = bytes.13
if first == 127 || first == 10 || (first == 192 && second == 168) {
freeaddrinfo(res)
return true
}
if first == 172 && (16...31).contains(second) {
freeaddrinfo(res)
return true
}
if first == 169 && second == 254 {
freeaddrinfo(res)
return true
}
}
}
current = info.pointee.ai_next
}
freeaddrinfo(res)
}
return false
}
+1 -1
View File
@@ -93,7 +93,7 @@ struct FirelinkApp: App {
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer = LocalExtensionServer(downloadController: controller, settings: settings)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
}
@@ -30,7 +30,8 @@ enum KeychainCredentialStore {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecValueData as String: Data(password.utf8)
kSecValueData as String: Data(password.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
+22 -13
View File
@@ -9,20 +9,24 @@ final class LocalExtensionServer: @unchecked Sendable {
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
// Firelink Companion 1.0.7+ sends this token. Keep accepted tokens here
// when future store releases need a non-breaking local API transition.
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
// Firelink Companion sends this token.
// We now use a dynamic token generated in AppSettings, but fallback to this
// for backward compatibility during the extension rollout if needed, though
// we'll enforce the dynamic token strictly in the processRequest method.
static let legacyExtensionToken = "firelink-extension-v1"
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
private let listener: NWListener
private let downloadController: DownloadController
private let settings: AppSettings
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
init?(downloadController: DownloadController) {
init?(downloadController: DownloadController, settings: AppSettings) {
self.downloadController = downloadController
self.settings = settings
let parameters = NWParameters.tcp
var createdListener: NWListener?
@@ -124,10 +128,6 @@ final class LocalExtensionServer: @unchecked Sendable {
}
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
guard request.path == "/download" else {
return .notFound
}
let host = request.header(named: "host") ?? ""
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
@@ -138,13 +138,22 @@ final class LocalExtensionServer: @unchecked Sendable {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
guard request.method == "POST" else {
return .methodNotAllowed
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
guard let token = request.header(named: Constants.extensionRequestHeader),
token == expectedToken else {
return .forbidden
}
guard let token = request.header(named: Constants.extensionRequestHeader),
Constants.supportedExtensionTokens.contains(token) else {
return .forbidden
if request.path == "/ping" {
return request.method == "GET" ? .ok : .methodNotAllowed
}
guard request.path == "/download" else {
return .notFound
}
guard request.method == "POST" else {
return .methodNotAllowed
}
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
+8 -7
View File
@@ -139,18 +139,19 @@ enum MediaExtractionEngine {
}
private static func executablePath(named name: String, candidates: [String]) -> String? {
if let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return path
}
var safeCandidates = candidates
safeCandidates.append(contentsOf: [
"/opt/homebrew/bin/\(name)",
"/usr/local/bin/\(name)",
"/usr/bin/\(name)",
"/opt/local/bin/\(name)"
])
let pathEnvironment = ProcessInfo.processInfo.environment["PATH"] ?? ""
for directory in pathEnvironment.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(name).path
for candidate in safeCandidates {
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
@@ -3,51 +3,80 @@ import SwiftUI
struct IntegrationSettingsPane: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@State private var showToast = false
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
ScrollView {
VStack(spacing: 24) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
Text("Connect Browser Extension")
.font(.title.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.body)
}
Spacer()
}
.padding(.bottom, 8)
// Step 1: Copy Token
StepCardView(
stepNumber: 1,
title: "Copy Pairing Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token"
) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
}
}
.padding(.vertical, 4)
}
Section("Installation") {
VStack(alignment: .leading, spacing: 16) {
Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.")
// Step 2: Open Browser
StepCardView(
stepNumber: 2,
title: "Open Firefox",
description: "Launch your browser. If you haven't installed the extension yet, do that first.",
icon: "safari.fill",
iconColor: .orange,
actionText: "Open Firefox"
) {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
}
// Step 3: Paste and Save
StepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
Divider()
// Diagnostics
HStack {
Text("Diagnostics:")
.foregroundStyle(.secondary)
Button {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
} label: {
Label("Install on Firefox", systemImage: "arrow.down.app")
.font(.headline)
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.tint(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) // Firefox Orange
.controlSize(.large)
}
.padding(.vertical, 8)
}
Section("Diagnostics") {
LabeledContent("Local receiver") {
Spacer()
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
@@ -56,14 +85,86 @@ struct IntegrationSettingsPane: View {
.foregroundStyle(.orange)
}
}
}
.font(.footnote)
.padding(.top, 8)
Section("Permissions & Privacy") {
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)
}
.padding(32)
}
.formStyle(.grouped)
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor))
}
}
struct StepCardView: View {
let stepNumber: Int
let title: String
let description: String
let icon: String
let iconColor: Color
let actionText: String?
let action: (() -> Void)?
var body: some View {
HStack(spacing: 16) {
// Step Number Badge
ZStack {
Circle()
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 32, height: 32)
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
Text("\(stepNumber)")
.font(.system(.headline, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
// Icon
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(iconColor.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: icon)
.font(.system(size: 24))
.foregroundStyle(iconColor)
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
// Action Button
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -0,0 +1,45 @@
import SwiftUI
struct ToastNotification: ViewModifier {
var message: String
@Binding var isShowing: Bool
func body(content: Content) -> some View {
ZStack(alignment: .bottom) {
content
if isShowing {
VStack {
Spacer()
Text(message)
.font(.subheadline.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Capsule()
.fill(Color.black.opacity(0.8))
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 24)
}
.zIndex(1)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isShowing = false
}
}
}
}
}
}
}
extension View {
func toast(isShowing: Binding<Bool>, message: String) -> some View {
self.modifier(ToastNotification(message: message, isShowing: isShowing))
}
}