1+ import CoreFoundation
12import Foundation
23#if canImport(FoundationNetworking)
34import 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