Skip to content

Commit d19908f

Browse files
committed
fix(session-replay): Add detection for potential PII leaks disabling session replay
1 parent ad5f74f commit d19908f

File tree

6 files changed

+181
-2
lines changed

6 files changed

+181
-2
lines changed

Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ public struct SentrySDKWrapper {
6161
)
6262
let defaultReplayQuality = options.sessionReplay.quality
6363
options.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality(rawValue: (SentrySDKOverrides.SessionReplay.quality.stringValue as? NSString)?.integerValue ?? defaultReplayQuality.rawValue) ?? defaultReplayQuality
64+
65+
options.sessionReplay.disableInDangerousEnvironment = false
6466
}
6567

6668
#if !os(tvOS)

Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
1616
public static let enableViewRendererV2: Bool = true
1717
public static let enableFastViewRendering: Bool = false
1818
public static let quality: SentryReplayQuality = .medium
19+
public static let disableInDangerousEnvironment: Bool = true
1920

2021
// The following properties are public because they are used by SentrySwiftUI.
2122

@@ -215,6 +216,17 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
215216
*/
216217
public var enableFastViewRendering: Bool
217218

219+
/**
220+
* Due to internal changes with the release of Liquid Glass on iOS 26.0, the masking of text and images can not be reliably guaranteed.
221+
*
222+
* Therefore the session replay integration is disabled by default starting with `8.57.0` as a defensive mechanism.
223+
*
224+
* - Important: This flag allows to re-enable the session replay integration on iOS 26.0 and later, but please be aware that text and images may not be masked as expected.
225+
*
226+
* - Note: See [GitHub issues #1234](https://github.com/getsentry/sentry-cocoa/issue/1234) for more information.
227+
*/
228+
public var disableInDangerousEnvironment: Bool
229+
218230
/**
219231
* Defines the quality of the session replay.
220232
*
@@ -290,6 +302,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
290302
maskAllImages: nil,
291303
enableViewRendererV2: nil,
292304
enableFastViewRendering: nil,
305+
disableInDangerousEnvironment: nil,
293306
maskedViewClasses: nil,
294307
unmaskedViewClasses: nil,
295308
quality: nil,
@@ -319,6 +332,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
319332
enableViewRendererV2: (dictionary["enableViewRendererV2"] as? NSNumber)?.boolValue
320333
?? (dictionary["enableExperimentalViewRenderer"] as? NSNumber)?.boolValue,
321334
enableFastViewRendering: (dictionary["enableFastViewRendering"] as? NSNumber)?.boolValue,
335+
disableInDangerousEnvironment: (dictionary["disableInDangerousEnvironment"] as? NSNumber)?.boolValue,
322336
maskedViewClasses: (dictionary["maskedViewClasses"] as? NSArray)?.compactMap({ element in
323337
NSClassFromString((element as? String) ?? "")
324338
}),
@@ -353,7 +367,8 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
353367
maskAllText: Bool = DefaultValues.maskAllText,
354368
maskAllImages: Bool = DefaultValues.maskAllImages,
355369
enableViewRendererV2: Bool = DefaultValues.enableViewRendererV2,
356-
enableFastViewRendering: Bool = DefaultValues.enableFastViewRendering
370+
enableFastViewRendering: Bool = DefaultValues.enableFastViewRendering,
371+
disableInDangerousEnvironment: Bool = DefaultValues.disableInDangerousEnvironment
357372
) {
358373
// - This initializer is publicly available for Swift, but not for Objective-C, because automatically bridged Swift initializers
359374
// with default values result in a single initializer requiring all parameters.
@@ -368,6 +383,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
368383
maskAllImages: maskAllImages,
369384
enableViewRendererV2: enableViewRendererV2,
370385
enableFastViewRendering: enableFastViewRendering,
386+
disableInDangerousEnvironment: disableInDangerousEnvironment,
371387
maskedViewClasses: nil,
372388
unmaskedViewClasses: nil,
373389
quality: nil,
@@ -387,6 +403,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
387403
maskAllImages: Bool?,
388404
enableViewRendererV2: Bool?,
389405
enableFastViewRendering: Bool?,
406+
disableInDangerousEnvironment: Bool?,
390407
maskedViewClasses: [AnyClass]?,
391408
unmaskedViewClasses: [AnyClass]?,
392409
quality: SentryReplayQuality?,
@@ -402,6 +419,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
402419
self.maskAllImages = maskAllImages ?? DefaultValues.maskAllImages
403420
self.enableViewRendererV2 = enableViewRendererV2 ?? DefaultValues.enableViewRendererV2
404421
self.enableFastViewRendering = enableFastViewRendering ?? DefaultValues.enableFastViewRendering
422+
self.disableInDangerousEnvironment = disableInDangerousEnvironment ?? DefaultValues.disableInDangerousEnvironment
405423
self.maskedViewClasses = maskedViewClasses ?? DefaultValues.maskedViewClasses
406424
self.unmaskedViewClasses = unmaskedViewClasses ?? DefaultValues.unmaskedViewClasses
407425
self.quality = quality ?? DefaultValues.quality

Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// swiftlint:disable file_length
12
import Foundation
23
#if (os(iOS) || os(tvOS)) && !SENTRY_NO_UIKIT
34
@_implementationOnly import _SentryPrivate
@@ -73,6 +74,18 @@ import UIKit
7374
SentrySDKLog.debug("[Session Replay] Session replay is already running, not starting again")
7475
return
7576
}
77+
78+
// Detect if we are running on iOS 26.0 with Liquid Glass and disable session replay.
79+
// This needs to be done until masking for session replay is properly supported, as it can lead
80+
// to PII leaks otherwise.
81+
if isRunningInDangerousEnvironment() {
82+
if replayOptions.disableInDangerousEnvironment {
83+
SentrySDKLog.fatal("[Session Replay] Detected environment potentially causing PII leaks, disabling Session Replay. To override this mechanism, set `options.disableInDangerousEnvironment` to `false`")
84+
return
85+
}
86+
SentrySDKLog.warning("[Session Replay] Detected environment potentially causing PII leaks, but `options.disableInDangerousEnvironment` is set to `false`, ignoring and enabling Session Replay.")
87+
}
88+
7689
displayLink.link(withTarget: self, selector: #selector(newFrame(_:)))
7790
self.rootView = rootView
7891
lastScreenShot = dateProvider.date()
@@ -369,7 +382,51 @@ import UIKit
369382
replayMaker.addFrameAsync(timestamp: timestamp, maskedViewImage: maskedViewImage, forScreen: screen)
370383
}
371384
}
385+
386+
private func isRunningInDangerousEnvironment() -> Bool {
387+
// Defensive programming: Assume dangerous environment by default on iOS 26.0+
388+
// and only mark as safe if we have explicit proof it's not using Liquid Glass.
389+
//
390+
// Liquid Glass introduces changes to text rendering that breaks masking in Session Replay.
391+
// It's used on iOS 26.0+ UNLESS one of these conditions is met:
392+
// 1. UIDesignRequiresCompatibility is explicitly set to YES in Info.plist
393+
// 2. The app was built with Xcode < 26.0 (DTXcode < 2600)
394+
395+
// First check: Are we even on iOS 26.0+?
396+
guard #available(iOS 26.0, *) else {
397+
// Not on iOS 26.0+ - safe to use Session Replay
398+
return false
399+
}
400+
401+
// We're on iOS 26.0+ - assume dangerous unless proven otherwise
402+
guard let infoDictionary = Bundle.main.infoDictionary else {
403+
// Can't read Info.plist - stay defensive
404+
SentrySDKLog.debug("[Session Replay] Running on iOS 26.0+ but cannot read Info.plist - treating as dangerous")
405+
return true
406+
}
407+
408+
// Safety check 1: Is compatibility mode explicitly enabled?
409+
if let requiresCompatibility = infoDictionary["UIDesignRequiresCompatibility"] as? Bool,
410+
requiresCompatibility == true {
411+
SentrySDKLog.debug("[Session Replay] Running on iOS 26.0+ with UIDesignRequiresCompatibility=YES - safe to use")
412+
return false
413+
}
414+
415+
// Safety check 2: Was the app built with an older Xcode version?
416+
// DTXcode format: Xcode 16.4 = "1640", Xcode 26.0 = "2600"
417+
if let xcodeVersionString = infoDictionary["DTXcode"] as? String,
418+
let xcodeVersion = Int(xcodeVersionString),
419+
xcodeVersion < 2_600 {
420+
SentrySDKLog.debug("[Session Replay] Running on iOS 26.0+ but built with Xcode \(xcodeVersionString) (< 26.0) - safe to use")
421+
return false
422+
}
423+
424+
// No safety conditions met - treat as dangerous
425+
SentrySDKLog.debug("[Session Replay] Running on iOS 26.0+ with Liquid Glass likely active - blocking Session Replay")
426+
return true
427+
}
372428
}
373429
// swiftlint:enable type_body_length
374430

375431
#endif
432+
// swiftlint:enable file_length

Tests/SentryTests/Integrations/SessionReplay/SentryReplayOptionsTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class SentryReplayOptionsTests: XCTestCase {
1616
XCTAssertTrue(options.maskAllImages)
1717
XCTAssertTrue(options.enableViewRendererV2)
1818
XCTAssertFalse(options.enableFastViewRendering)
19+
XCTAssertTrue(options.disableInDangerousEnvironment)
1920

2021
XCTAssertEqual(options.maskedViewClasses.count, 0)
2122
XCTAssertEqual(options.unmaskedViewClasses.count, 0)
@@ -850,4 +851,58 @@ class SentryReplayOptionsTests: XCTestCase {
850851
// -- Assert --
851852
XCTAssertNil(options.sdkInfo)
852853
}
854+
855+
// MARK: disableInDangerousEnvironment
856+
857+
func testInit_disableInDangerousEnvironment_shouldDefaultToTrue() {
858+
// -- Act --
859+
let options = SentryReplayOptions()
860+
861+
// -- Assert --
862+
XCTAssertTrue(options.disableInDangerousEnvironment)
863+
}
864+
865+
func testInit_disableInDangerousEnvironment_whenSetToFalse_shouldAllowOptIn() {
866+
// -- Act --
867+
let options = SentryReplayOptions(
868+
sessionSampleRate: 1.0,
869+
onErrorSampleRate: 1.0,
870+
disableInDangerousEnvironment: false
871+
)
872+
873+
// -- Assert --
874+
XCTAssertFalse(options.disableInDangerousEnvironment)
875+
}
876+
877+
func testInitFromDict_disableInDangerousEnvironment_whenValidValue_shouldSetValue() {
878+
// -- Act --
879+
let optionsTrue = SentryReplayOptions(dictionary: [
880+
"disableInDangerousEnvironment": true
881+
])
882+
let optionsFalse = SentryReplayOptions(dictionary: [
883+
"disableInDangerousEnvironment": false
884+
])
885+
886+
// -- Assert --
887+
XCTAssertTrue(optionsTrue.disableInDangerousEnvironment)
888+
XCTAssertFalse(optionsFalse.disableInDangerousEnvironment)
889+
}
890+
891+
func testInitFromDict_disableInDangerousEnvironment_whenInvalidValue_shouldUseDefaultValue() {
892+
// -- Act --
893+
let options = SentryReplayOptions(dictionary: [
894+
"disableInDangerousEnvironment": "invalid_value"
895+
])
896+
897+
// -- Assert --
898+
XCTAssertTrue(options.disableInDangerousEnvironment)
899+
}
900+
901+
func testInitFromDict_disableInDangerousEnvironment_whenNotSpecified_shouldUseDefaultValue() {
902+
// -- Act --
903+
let options = SentryReplayOptions(dictionary: [:])
904+
905+
// -- Assert --
906+
XCTAssertTrue(options.disableInDangerousEnvironment)
907+
}
853908
}

Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class SentrySessionReplayIntegrationTests: XCTestCase {
3030
}
3131

3232
if #available(iOS 26.0, tvOS 26.0, macCatalyst 26.0, *) {
33-
throw XCTSkip("When running the unit tests on iOS 26.0, tvOS 26 or macCatalyst 26.0 with Xcode 26.0, we get warning log messages on the console: 'nw_socket_set_connection_idle [C1.1.1.1:3] setsockopt SO_CONNECTION_IDLE failed [42: Protocol not available]'. This leads to test failures in CI. Therefore, we skip these for now. We are going to fix this with https://github.com/getsentry/sentry-cocoa/issues/6165.")
33+
throw XCTSkip("When running the unit tests on iOS 26.0, tvOS 26 or macCatalyst 26.0 with Xcode 26.0, we get warning log messages on the console: 'nw_socket_set_connection_idle [C1.1.1.1:3] setsockopt SO_CONNECTION_IDLE failed [42: Protocol not available]'. This leads to test failures in CI. Therefore, we skip these for now. We are going to fix this with https://github.com/getsentry/sentry-cocoa/issues/6165. Note: Session Replay is also disabled by default on iOS 26 due to Liquid Glass rendering changes.")
3434
}
3535

3636
uiApplication = TestSentryUIApplication()

Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,53 @@ class SentrySessionReplayTests: XCTestCase {
551551
private func assertFullSession(_ sessionReplay: SentrySessionReplay, expected: Bool) {
552552
XCTAssertEqual(sessionReplay.isFullSession, expected)
553553
}
554+
555+
// MARK: - iOS 26 Liquid Glass Detection Tests
556+
557+
@available(iOS 26.0, *)
558+
func testBlocksSessionReplayOnIOS26WithLiquidGlass() {
559+
// This test will only run on iOS 26.0+
560+
// It tests that session replay is blocked when Liquid Glass is detected
561+
let fixture = Fixture()
562+
let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1))
563+
564+
// Attempt to start session replay
565+
sut.start(rootView: fixture.rootView, fullSession: true)
566+
567+
// Verify that session replay did not actually start
568+
// (it should have been blocked by isRunningInDangerousEnvironment)
569+
XCTAssertFalse(fixture.displayLink.isRunning())
570+
}
571+
572+
@available(iOS 26.0, *)
573+
func testAllowsSessionReplayOnIOS26WhenDisabledViaOption() {
574+
// This test verifies that users can explicitly opt-in to session replay on iOS 26
575+
let fixture = Fixture()
576+
let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)
577+
options.disableInDangerousEnvironment = false
578+
let sut = fixture.getSut(options: options)
579+
580+
// Attempt to start session replay
581+
sut.start(rootView: fixture.rootView, fullSession: true)
582+
583+
// Verify that session replay started despite iOS 26
584+
XCTAssertTrue(fixture.displayLink.isRunning())
585+
}
586+
587+
func testAllowsSessionReplayOnIOS25AndEarlier() throws {
588+
// This test runs on iOS < 26 and verifies session replay works normally
589+
if #available(iOS 26.0, *) {
590+
throw XCTSkip("This test is for iOS < 26.0")
591+
}
592+
593+
let fixture = Fixture()
594+
let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1))
595+
596+
// Session replay should start normally on older iOS versions
597+
sut.start(rootView: fixture.rootView, fullSession: true)
598+
599+
XCTAssertTrue(fixture.displayLink.isRunning())
600+
}
554601
}
555602

556603
#endif

0 commit comments

Comments
 (0)