Skip to content

Commit 28c8728

Browse files
committed
fix: harden MiMo Firefox session recovery
1 parent 6c6945d commit 28c8728

3 files changed

Lines changed: 319 additions & 110 deletions

File tree

Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift

Lines changed: 106 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CoreFoundation
12
import Foundation
23
#if canImport(FoundationNetworking)
34
import FoundationNetworking
@@ -226,27 +227,25 @@ public enum MiMoCookieImporter {
226227
for store in stores where store.browser == browser {
227228
let profileID = store.profile.id
228229
let source = resolvedByProfileID[profileID] ?? BrowserCookieStoreRecords(store: store, records: [])
229-
let profileDirectory = URL(fileURLWithPath: profileID, isDirectory: true)
230+
guard let profileDirectory = store.databaseURL?.deletingLastPathComponent() else { continue }
230231
let sessionRecords = MiMoFirefoxSessionCookieImporter.records(
231232
profileDirectory: profileDirectory,
232-
persistedRecords: source.records,
233233
logger: logger)
234-
guard !sessionRecords.isEmpty else {
234+
let sessionCookies = BrowserCookieClient.makeHTTPCookies(sessionRecords, origin: .domainBased)
235+
guard MiMoCookieHeader.header(from: sessionCookies) != nil else {
235236
if resolvedByProfileID[profileID] == nil {
236237
continue
237238
}
238239
resolvedByProfileID[profileID] = source
239240
continue
240241
}
241242

242-
let sessionKeys = Set(sessionRecords.map(self.recordKey))
243-
let persistedRecords = source.records.filter { !sessionKeys.contains(self.recordKey($0)) }
244243
logger?(
245244
"\(source.label): recovered \(sessionRecords.count) MiMo session cookie(s) " +
246245
"from Firefox session restore")
247246
resolvedByProfileID[profileID] = BrowserCookieStoreRecords(
248247
store: source.store,
249-
records: persistedRecords + sessionRecords)
248+
records: sessionRecords)
250249

251250
if !orderedProfileIDs.contains(profileID) {
252251
orderedProfileIDs.append(profileID)
@@ -349,6 +348,7 @@ enum MiMoFirefoxSessionCookieImporter {
349348
enum ImportError: LocalizedError {
350349
case inputTooLarge
351350
case outputTooLarge
351+
case resourceLimit(String)
352352
case invalidData(String)
353353

354354
var errorDescription: String? {
@@ -357,6 +357,8 @@ enum MiMoFirefoxSessionCookieImporter {
357357
"Firefox session restore file exceeds the 64 MiB safety limit."
358358
case .outputTooLarge:
359359
"Firefox session restore data exceeds the 128 MiB safety limit."
360+
case let .resourceLimit(message):
361+
message
360362
case let .invalidData(message):
361363
message
362364
}
@@ -365,60 +367,42 @@ enum MiMoFirefoxSessionCookieImporter {
365367

366368
private static let maxInputBytes = 64 * 1024 * 1024
367369
private static let maxOutputBytes = 128 * 1024 * 1024
368-
private static let maxJSONNodes = 1_000_000
369370
private static let maxCookieRecords = 4096
370371
private static let sessionRestoreFileNames = [
371372
"recovery.jsonlz4",
372373
"recovery.baklz4",
373374
"previous.jsonlz4",
374-
"sessionstore.jsonlz4",
375375
]
376376
private static let mozillaLZ4Magic = Data([0x6D, 0x6F, 0x7A, 0x4C, 0x7A, 0x34, 0x30, 0x00])
377377

378378
static func records(
379379
profileDirectory: URL,
380-
persistedRecords: [BrowserCookieRecord] = [],
381380
now: Date = Date(),
382381
logger: ((String) -> Void)? = nil) -> [BrowserCookieRecord]
383382
{
384383
let files = self.sessionRestoreFiles(profileDirectory: profileDirectory)
385-
var firstPartialRecords: [BrowserCookieRecord] = []
386384
for file in files {
387385
do {
388386
let data = try self.readData(from: file)
389387
let jsonData = try self.decodeSessionRestoreData(data)
390388
let records = try self.cookieRecords(fromJSONData: jsonData, now: now)
391-
guard !records.isEmpty else { continue }
392-
let isCurrentRestore = file.lastPathComponent == "recovery.jsonlz4" ||
393-
(file.lastPathComponent == "sessionstore.jsonlz4" &&
394-
file.deletingLastPathComponent().standardizedFileURL == profileDirectory.standardizedFileURL)
395-
let persistedKeys = Set(persistedRecords.map { "\($0.name)|\($0.domain)|\($0.path)" })
396-
let candidateRecords = isCurrentRestore ? records : records.filter { record in
397-
!persistedKeys.contains("\(record.name)|\(record.domain)|\(record.path)")
398-
}
399-
guard !candidateRecords.isEmpty else { continue }
400-
if firstPartialRecords.isEmpty {
401-
firstPartialRecords = candidateRecords
402-
}
403-
let candidateKeys = Set(candidateRecords.map { "\($0.name)|\($0.domain)|\($0.path)" })
404-
let resolvedRecords = persistedRecords.filter { record in
405-
!candidateKeys.contains("\(record.name)|\(record.domain)|\(record.path)")
406-
} + candidateRecords
407-
let cookies = BrowserCookieClient.makeHTTPCookies(resolvedRecords, origin: .domainBased)
408-
if MiMoCookieHeader.header(from: cookies) != nil {
409-
logger?(
410-
"\(profileDirectory.lastPathComponent): found MiMo session cookies through " +
411-
"\(file.lastPathComponent)")
412-
return candidateRecords
413-
}
414-
logger?("\(profileDirectory.lastPathComponent): partial MiMo cookies in \(file.lastPathComponent)")
389+
logger?(
390+
"\(profileDirectory.lastPathComponent): read \(records.count) MiMo session cookie(s) " +
391+
"from \(file.lastPathComponent)")
392+
// The first valid Firefox state is authoritative, even when it records logout or partial auth.
393+
return records
394+
} catch ImportError.inputTooLarge, ImportError.outputTooLarge, ImportError.resourceLimit {
395+
logger?(
396+
"\(profileDirectory.lastPathComponent): rejected unsafe Firefox session restore " +
397+
"\(file.lastPathComponent)")
398+
return []
415399
} catch {
416400
logger?(
417401
"\(profileDirectory.lastPathComponent): could not read Firefox session restore " +
418402
"\(file.lastPathComponent): \(error.localizedDescription)")
419403
}
420404
}
421-
return firstPartialRecords
405+
return []
422406
}
423407

424408
static func readData(
@@ -433,18 +417,15 @@ enum MiMoFirefoxSessionCookieImporter {
433417
return data
434418
}
435419

436-
private static func sessionRestoreFiles(profileDirectory: URL) -> [URL] {
420+
static func sessionRestoreFiles(profileDirectory: URL) -> [URL] {
437421
let backupDirectory = profileDirectory.appendingPathComponent("sessionstore-backups", isDirectory: true)
438-
var files = self.sessionRestoreFileNames.map { backupDirectory.appendingPathComponent($0) }
439-
files.append(profileDirectory.appendingPathComponent("sessionstore.jsonlz4"))
440-
441-
if let upgradeFiles = try? FileManager.default.contentsOfDirectory(
422+
let upgradeFiles = (try? FileManager.default.contentsOfDirectory(
442423
at: backupDirectory,
443424
includingPropertiesForKeys: nil,
444-
options: [.skipsHiddenFiles])
445-
{
446-
files.append(contentsOf: upgradeFiles.filter { $0.lastPathComponent.hasPrefix("upgrade.jsonlz4-") })
447-
}
425+
options: [.skipsHiddenFiles])) ?? []
426+
let files = self.orderedSessionRestoreFileCandidates(
427+
profileDirectory: profileDirectory,
428+
upgradeFiles: upgradeFiles)
448429

449430
var seen = Set<String>()
450431
return files.filter { file in
@@ -454,61 +435,66 @@ enum MiMoFirefoxSessionCookieImporter {
454435
}
455436
}
456437

438+
static func orderedSessionRestoreFileCandidates(
439+
profileDirectory: URL,
440+
upgradeFiles: [URL]) -> [URL]
441+
{
442+
let backupDirectory = profileDirectory.appendingPathComponent("sessionstore-backups", isDirectory: true)
443+
let newestUpgrade = upgradeFiles
444+
.filter { $0.lastPathComponent.hasPrefix("upgrade.jsonlz4-") }
445+
.max { $0.lastPathComponent < $1.lastPathComponent }
446+
return [profileDirectory.appendingPathComponent("sessionstore.jsonlz4")] +
447+
self.sessionRestoreFileNames.map { backupDirectory.appendingPathComponent($0) } +
448+
(newestUpgrade.map { [$0] } ?? [])
449+
}
450+
457451
static func decodeSessionRestoreData(
458452
_ data: Data,
459453
maxOutputBytes: Int = MiMoFirefoxSessionCookieImporter.maxOutputBytes) throws -> Data
460454
{
461455
guard maxOutputBytes >= 0 else { throw ImportError.outputTooLarge }
462456
guard data.starts(with: self.mozillaLZ4Magic) else {
463-
guard data.count <= maxOutputBytes else { throw ImportError.outputTooLarge }
464-
return data
457+
throw ImportError.invalidData("Invalid Firefox session restore header.")
465458
}
466459
let payload = data.dropFirst(self.mozillaLZ4Magic.count)
467-
let directError: Error
468-
do {
469-
return try self.decodeLZ4Block(Data(payload), maxOutputBytes: maxOutputBytes)
470-
} catch {
471-
directError = error
472-
// Some jsonlz4 writers prefix the raw block with its decoded byte count.
473-
}
474-
guard payload.count > 4 else {
475-
throw directError
476-
}
477-
do {
478-
return try self.decodeLZ4Block(Data(payload.dropFirst(4)), maxOutputBytes: maxOutputBytes)
479-
} catch {
480-
if case ImportError.outputTooLarge = directError {
481-
throw ImportError.outputTooLarge
482-
}
483-
throw error
484-
}
460+
guard payload.count >= 4 else {
461+
throw ImportError.invalidData("Invalid Firefox session restore size header.")
462+
}
463+
let sizeBytes = Array(payload.prefix(4))
464+
let declaredSize = Int(sizeBytes[0]) |
465+
(Int(sizeBytes[1]) << 8) |
466+
(Int(sizeBytes[2]) << 16) |
467+
(Int(sizeBytes[3]) << 24)
468+
guard declaredSize <= maxOutputBytes else { throw ImportError.outputTooLarge }
469+
let decoded = try self.decodeLZ4Block(Data(payload.dropFirst(4)), maxOutputBytes: declaredSize)
470+
guard decoded.count == declaredSize else {
471+
throw ImportError.invalidData("Firefox session restore decoded size does not match its header.")
472+
}
473+
return decoded
485474
}
486475

487476
static func cookieRecords(
488477
fromJSONData data: Data,
489478
now: Date = Date(),
490-
maxNodes: Int = MiMoFirefoxSessionCookieImporter.maxJSONNodes,
491479
maxRecords: Int = MiMoFirefoxSessionCookieImporter.maxCookieRecords) throws -> [BrowserCookieRecord]
492480
{
493-
let root = try JSONSerialization.jsonObject(with: data)
494-
var stack: [Any] = [root]
481+
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
482+
throw ImportError.invalidData("Firefox session restore root is not an object.")
483+
}
484+
guard let rawCookies = root["cookies"] else { return [] }
485+
guard let cookies = rawCookies as? [Any] else {
486+
throw ImportError.invalidData("Firefox session restore cookies are not an array.")
487+
}
488+
guard cookies.count <= maxRecords else {
489+
throw ImportError.resourceLimit("Firefox session restore contains too many cookie records.")
490+
}
495491
var records: [BrowserCookieRecord] = []
496-
var visitedNodes = 0
497-
while let value = stack.popLast() {
498-
visitedNodes += 1
499-
guard visitedNodes <= maxNodes else {
500-
throw ImportError.invalidData("Firefox session restore JSON is too complex.")
492+
for rawCookie in cookies {
493+
guard let cookie = rawCookie as? [String: Any] else {
494+
throw ImportError.invalidData("Firefox session restore contains a malformed cookie record.")
501495
}
502-
if let dictionary = value as? [String: Any] {
503-
if let record = self.cookieRecord(from: dictionary, now: now) {
504-
records.append(record)
505-
guard records.count <= maxRecords else {
506-
throw ImportError.invalidData("Firefox session restore contains too many cookie records.")
507-
}
508-
}
509-
stack.append(contentsOf: dictionary.values)
510-
} else if let array = value as? [Any] {
511-
stack.append(contentsOf: array)
496+
if let record = self.cookieRecord(from: cookie, now: now) {
497+
records.append(record)
512498
}
513499
}
514500
return records
@@ -523,6 +509,7 @@ enum MiMoFirefoxSessionCookieImporter {
523509
else {
524510
return nil
525511
}
512+
guard self.hasDefaultOriginAttributes(dictionary) else { return nil }
526513

527514
let domain = host.trimmingCharacters(in: CharacterSet(charactersIn: "."))
528515
guard self.domainMatchesMiMo(domain) else { return nil }
@@ -541,6 +528,43 @@ enum MiMoFirefoxSessionCookieImporter {
541528
isHTTPOnly: (dictionary["httponly"] as? Bool) ?? (dictionary["httpOnly"] as? Bool) ?? false)
542529
}
543530

531+
private static func hasDefaultOriginAttributes(_ dictionary: [String: Any]) -> Bool {
532+
if let isPartitioned = dictionary["isPartitioned"] {
533+
guard self.isBoolean(isPartitioned, equalTo: false) else { return false }
534+
}
535+
guard let rawAttributes = dictionary["originAttributes"] else { return true }
536+
if let attributes = rawAttributes as? String {
537+
return attributes.isEmpty
538+
}
539+
guard let attributes = rawAttributes as? [String: Any] else { return false }
540+
for (key, value) in attributes {
541+
switch key {
542+
case "userContextId", "privateBrowsingId":
543+
guard self.isZero(value) else { return false }
544+
case "firstPartyDomain", "geckoViewSessionContextId", "partitionKey":
545+
guard let text = value as? String, text.isEmpty else { return false }
546+
default:
547+
return false
548+
}
549+
}
550+
return true
551+
}
552+
553+
private static func isZero(_ value: Any) -> Bool {
554+
guard let number = value as? NSNumber,
555+
CFGetTypeID(number) != CFBooleanGetTypeID(),
556+
!["f", "d"].contains(String(cString: number.objCType))
557+
else { return false }
558+
return number.int64Value == 0
559+
}
560+
561+
private static func isBoolean(_ value: Any, equalTo expected: Bool) -> Bool {
562+
guard let number = value as? NSNumber,
563+
CFGetTypeID(number) == CFBooleanGetTypeID()
564+
else { return false }
565+
return number.boolValue == expected
566+
}
567+
544568
private static func cookiePath(from dictionary: [String: Any]) -> String {
545569
guard let path = dictionary["path"] as? String, !path.isEmpty else {
546570
return "/"

0 commit comments

Comments
 (0)