From ebbe233781c873d844186ae605eb312d65e5bfff Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 13:09:45 -0400 Subject: [PATCH 1/3] fix(replay): mask content drawn outside a view's bounds Screenshot masks only covered each masked view's bounds, so content the view draws past them stayed visible: an unclipped image view's image under aspect-fill or a natural-size content mode, and an unclipped layer shadow (which repeats text). Mask rects now grow by what the view draws past its bounds. A masked UIButton now also walks its subviews, so a title or image laid out past the button by insets gets its own mask. Labels and text views that hold a non-symbol, non-asset image attachment are masked when maskAllImages is on, since no image view exists for the attachment. Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-major-release.md | 1 + .../Replay/PostHogMaskSettleAnalysis.swift | 5 +- PostHog/Replay/PostHogReplayIntegration.swift | 29 +++- PostHog/Replay/UIView+Util.swift | 90 +++++++++++- PostHogTests/PostHogMaskFailClosedTest.swift | 133 +++++++++++++++++- 5 files changed, 246 insertions(+), 12 deletions(-) diff --git a/.changeset/v4-major-release.md b/.changeset/v4-major-release.md index 594345bfa..5fea8c993 100644 --- a/.changeset/v4-major-release.md +++ b/.changeset/v4-major-release.md @@ -49,3 +49,4 @@ PostHog iOS 4.0. Every breaking change below names what to use instead. - Fix session replay recording nothing for apps whose window root is a SwiftUI view. - Fix secure-entry text fields being unmasked inside a `postHogNoMask()` or `ph-no-mask` subtree. +- Fix session replay leaving masked content visible where it is drawn outside its view (unclipped images, layer shadows, button titles) and image attachments in text unmasked when `maskAllImages` is on. diff --git a/PostHog/Replay/PostHogMaskSettleAnalysis.swift b/PostHog/Replay/PostHogMaskSettleAnalysis.swift index 24bbbb6ce..fd804f973 100644 --- a/PostHog/Replay/PostHogMaskSettleAnalysis.swift +++ b/PostHog/Replay/PostHogMaskSettleAnalysis.swift @@ -20,12 +20,13 @@ self.rect = rect } + /// Covers everything the view draws itself, including content past its bounds. init(_ view: UIView, in window: UIWindow?) { - self.init(view, rect: view.toPresentationRect(window)) + self.init(view, rect: view.toPresentationRect(window, outsets: view.drawnOutsets)) } init(_ layer: CALayer, in window: UIWindow?) { - self.init(layer, rect: layer.toPresentationRect(window)) + self.init(layer, rect: layer.toPresentationRect(window, outsets: layer.drawnOutsets(of: layer.bounds))) } } diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 5e7970a9b..ed566d53f 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -995,6 +995,8 @@ if let button = view as? UIButton { if isButtonSensitive(button) { maskableWidgets.append(.init(view, in: window)) + // title and image insets can lay the button's subviews out past its bounds + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture()) return } } @@ -1350,7 +1352,7 @@ } private func isLabelSensitive(_ view: UILabel) -> Bool { - isTextInputSensitive(view) && hasText(view.text) + (isTextInputSensitive(view) && hasText(view.text)) || hasSensitiveImageAttachment(view.attributedText) } private func isButtonSensitive(_ view: UIButton) -> Bool { @@ -1358,7 +1360,9 @@ } private func isTextViewSensitive(_ view: UITextView) -> Bool { - (isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text) + // textStorage rather than attributedText, which copies the whole text + ((isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text)) + || hasSensitiveImageAttachment(view.textStorage) } private func isSwitchSensitive(_ view: UISwitch) -> Bool { @@ -1369,6 +1373,27 @@ (isTextInputSensitive(view) || view.isSensitiveText()) && (hasText(view.text) || hasText(view.placeholder)) } + /// Image attachments in text are drawn by the label or text view itself, with no image view to + /// mask, so masking images masks text that holds one. Like image views, symbols and + /// asset-catalog images stay visible; an attachment whose image can't be read is masked. + private func hasSensitiveImageAttachment(_ text: NSAttributedString?) -> Bool { + guard config?.sessionReplayConfig.maskAllImages == true, let text, text.length > 0 else { + return false + } + var found = false + text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, _, stop in + guard let attachment = value as? NSTextAttachment else { + return + } + if let image = attachment.image, image.isSymbolImage || isAssetsImage(image) { + return + } + found = true + stop.pointee = true + } + return found + } + private func isSwiftUILayerSafe(_ layer: CALayer) -> Bool { swiftUISafeLayerTypes.contains(where: { layer.isKind(of: $0) }) } diff --git a/PostHog/Replay/UIView+Util.swift b/PostHog/Replay/UIView+Util.swift index d67232a3e..7864ec046 100644 --- a/PostHog/Replay/UIView+Util.swift +++ b/PostHog/Replay/UIView+Util.swift @@ -161,12 +161,67 @@ /// Mask geometry only. During a CA animation the model layer parks at the destination /// while the presentation layer holds the in-flight position the screenshot renders, so /// mask rects have to come from the presentation tree. - func toPresentationRect(_ window: UIWindow?) -> CGRect { + /// `outsets` grows the rect past the bounds, for content drawn outside them. + func toPresentationRect(_ window: UIWindow?, outsets: UIEdgeInsets = .zero) -> CGRect { guard layer.presentation() != nil else { // UIView's own convert, not the layer's: it resolves a nil window to the view's. - return convert(bounds, to: window) + return convert(bounds.outset(by: outsets), to: window) } - return layer.toPresentationRect(window) + return layer.toPresentationRect(window, outsets: outsets) + } + + /// How far past its bounds the view draws its own content: an image view that doesn't + /// clip draws its image at the size its content mode gives it, and an unclipped layer + /// shadow repeats that content at an offset. + var drawnOutsets: UIEdgeInsets { + var content = bounds + if !clipsToBounds, let imageView = self as? UIImageView { + content = imageView.drawnImageExtent + } + return layer.drawnOutsets(of: content) + } + } + + extension UIImageView { + /// The union of the bounds and where each image the view can draw lands, in bounds + /// coordinates. Only fill-to-bounds content modes keep the image inside the bounds. + var drawnImageExtent: CGRect { + switch contentMode { + case .scaleToFill, .scaleAspectFit, .redraw: + return bounds + default: + let sources = [image, highlightedImage].compactMap { $0 } + + (animationImages ?? []) + (highlightedAnimationImages ?? []) + return sources.reduce(bounds) { $0.union(drawnRect(ofImageSized: $1.size)) } + } + } + + private func drawnRect(ofImageSized imageSize: CGSize) -> CGRect { + var size = imageSize + if contentMode == .scaleAspectFill, imageSize.width > 0, imageSize.height > 0 { + let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height) + size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) + } + + let originX: CGFloat + switch contentMode { + case .left, .topLeft, .bottomLeft: originX = 0 + case .right, .topRight, .bottomRight: originX = bounds.width - size.width + default: originX = (bounds.width - size.width) / 2 + } + let originY: CGFloat + switch contentMode { + case .top, .topLeft, .topRight: originY = 0 + case .bottom, .bottomLeft, .bottomRight: originY = bounds.height - size.height + default: originY = (bounds.height - size.height) / 2 + } + return CGRect(x: bounds.minX + originX, y: bounds.minY + originY, width: size.width, height: size.height) + } + } + + extension CGRect { + func outset(by outsets: UIEdgeInsets) -> CGRect { + inset(by: UIEdgeInsets(top: -outsets.top, left: -outsets.left, bottom: -outsets.bottom, right: -outsets.right)) } } @@ -192,11 +247,34 @@ } extension CALayer { - func toPresentationRect(_ window: UIWindow?) -> CGRect { + func toPresentationRect(_ window: UIWindow?, outsets: UIEdgeInsets = .zero) -> CGRect { guard let presentationLayer = presentation() else { - return convert(bounds, to: window?.layer) + return convert(bounds.outset(by: outsets), to: window?.layer) + } + return presentationLayer.convert( + presentationLayer.bounds.outset(by: outsets), + to: window?.layer.presentation() ?? window?.layer + ) + } + + /// How far past `bounds` the layer draws, given `content`, the extent of its own drawing + /// in bounds coordinates. A shadow without a `shadowPath` repeats the content's shape, + /// text included, so it counts; `masksToBounds` clips the shadow away. + func drawnOutsets(of content: CGRect) -> UIEdgeInsets { + var drawn = content + if !masksToBounds, shadowOpacity > 0, shadowColor != nil, shadowPath == nil { + // Pad by twice the blur radius so the shadow's soft edge is covered too. + let blur = shadowRadius * 2 + drawn = drawn.union( + content.offsetBy(dx: shadowOffset.width, dy: shadowOffset.height).insetBy(dx: -blur, dy: -blur) + ) } - return presentationLayer.convert(presentationLayer.bounds, to: window?.layer.presentation() ?? window?.layer) + return UIEdgeInsets( + top: max(0, bounds.minY - drawn.minY), + left: max(0, bounds.minX - drawn.minX), + bottom: max(0, drawn.maxY - bounds.maxY), + right: max(0, drawn.maxX - bounds.maxX) + ) } /// Backing flag for the SwiftUI `.postHogNoRageClick()` modifier on layer-backed diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index e7dea373b..966e00579 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -61,10 +61,11 @@ struct PostHogMaskFailClosedTest { private typealias Sut = (sdk: PostHogSDK, integration: PostHogReplayIntegration) - private func makeSut() -> Sut { + private func makeSut(maskAllTextInputs: Bool = true, maskAllImages: Bool = true) -> Sut { let config = PostHogConfig(projectToken: "phc_maskFailClosedTest") config.disableReachabilityForTesting = true - config.sessionReplayConfig.maskAllTextInputs = true + config.sessionReplayConfig.maskAllTextInputs = maskAllTextInputs + config.sessionReplayConfig.maskAllImages = maskAllImages PostHogReplayIntegration.clearInstalls() let sdk = PostHogSDK.with(config) let integration = PostHogReplayIntegration() @@ -387,5 +388,133 @@ #expect(sut.integration.collectMaskableRects(in: window) == []) } + + // MARK: - Content drawn outside a masked view's bounds + + private func makePhoto(size: CGSize) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: size, format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } + + private func isCovered(_ target: CGRect, by rects: [CGRect]?) -> Bool { + rects?.contains { $0.contains(target) } == true + } + + @Test("an unclipped aspect-fill image is masked where it overflows its view", arguments: [false, true]) + func unclippedAspectFillImage(clipsToBounds: Bool) { + let sut = makeSut() + defer { teardown(sut) } + + let imageView = UIImageView(frame: CGRect(x: 100, y: 120, width: 100, height: 100)) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = clipsToBounds + imageView.image = makePhoto(size: CGSize(width: 200, height: 100)) + let window = makeWindow(containing: imageView) + + // Filling 100x100 with a 2:1 image draws it 200x100, centred: 50pt past each side. + let expected = clipsToBounds ? imageView.frame : CGRect(x: 50, y: 120, width: 200, height: 100) + #expect(sut.integration.collectMaskableRects(in: window) == [expected]) + } + + @Test("an unclipped image drawn at its natural size is masked where it overflows its view") + func unclippedTopLeftImage() { + let sut = makeSut() + defer { teardown(sut) } + + let imageView = UIImageView(frame: CGRect(x: 100, y: 120, width: 50, height: 50)) + imageView.contentMode = .topLeft + imageView.clipsToBounds = false + imageView.image = makePhoto(size: CGSize(width: 120, height: 80)) + let window = makeWindow(containing: imageView) + + #expect(sut.integration.collectMaskableRects(in: window) == [CGRect(x: 100, y: 120, width: 120, height: 80)]) + } + + private func attributedText(with attachment: NSTextAttachment) -> NSAttributedString { + let text = NSMutableAttributedString(string: "Receipt ") + text.append(NSAttributedString(attachment: attachment)) + return text + } + + @Test("an image attachment in text is masked when only images are masked", arguments: ["label", "textView"]) + func imageAttachmentInText(kind: String) { + let sut = makeSut(maskAllTextInputs: false, maskAllImages: true) + defer { teardown(sut) } + + let text = attributedText(with: NSTextAttachment(image: makePhoto(size: CGSize(width: 24, height: 24)))) + let frame = CGRect(x: 24, y: 120, width: 200, height: 40) + let view: UIView + switch kind { + case "label": + let label = UILabel(frame: frame) + label.attributedText = text + view = label + default: + let textView = UITextView(frame: frame) + textView.attributedText = text + view = textView + } + let window = makeWindow(containing: view) + + #expect(isCovered(frame, by: sut.integration.collectMaskableRects(in: window))) + } + + @Test("a symbol attachment in text stays visible when only images are masked") + func symbolAttachmentInLabel() throws { + let sut = makeSut(maskAllTextInputs: false, maskAllImages: true) + defer { teardown(sut) } + + let label = UILabel(frame: CGRect(x: 24, y: 120, width: 200, height: 40)) + label.attributedText = try attributedText(with: NSTextAttachment(image: #require(UIImage(systemName: "star")))) + let window = makeWindow(containing: label) + + #expect(sut.integration.collectMaskableRects(in: window) == []) + } + + @Test("an unclipped label's layer shadow is masked with the label", arguments: [false, true]) + func labelLayerShadow(clipsToBounds: Bool) { + let sut = makeSut() + defer { teardown(sut) } + + let label = makeSecretLabel(frame: CGRect(x: 24, y: 120, width: 200, height: 32)) + label.clipsToBounds = clipsToBounds + label.layer.shadowOpacity = 1 + label.layer.shadowOffset = CGSize(width: 0, height: 60) + label.layer.shadowRadius = 0 + let window = makeWindow(containing: label) + + let rects = sut.integration.collectMaskableRects(in: window) + if clipsToBounds { + // A clipping layer clips its shadow too, so the mask stays on the label. + #expect(rects == [label.frame]) + } else { + #expect(isCovered(label.frame.union(label.frame.offsetBy(dx: 0, dy: 60)), by: rects)) + } + } + + @Test("a masked button's title drawn outside the button is masked") + func buttonTitleOutsideBounds() throws { + let sut = makeSut() + defer { teardown(sut) } + + let button = UIButton(type: .system) + button.frame = CGRect(x: 24, y: 120, width: 100, height: 44) + button.clipsToBounds = false + button.setTitle("SSN 123-45-6789", for: .normal) + button.contentHorizontalAlignment = .left + button.titleEdgeInsets = UIEdgeInsets(top: 0, left: 200, bottom: 0, right: -200) + let window = makeWindow(containing: button) + button.layoutIfNeeded() + + let title = try #require(button.titleLabel) + let titleRect = title.toAbsoluteRect(window) + try #require(!button.frame.contains(titleRect), "the title must sit outside the button for this test") + + #expect(isCovered(titleRect, by: sut.integration.collectMaskableRects(in: window))) + } } #endif From 2278ab389d26efaca0c7b281a0ce0df3df250adb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 13:31:55 -0400 Subject: [PATCH 2/3] fix(replay): keep masking subviews of text masked for an image attachment A label or text view masked only for its image attachment returned before visiting its subviews, so an unclipped image overflowing it lost the mask it had before. --- PostHog/Replay/PostHogReplayIntegration.swift | 4 +++ PostHogTests/PostHogMaskFailClosedTest.swift | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index ed566d53f..b2a937dec 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -914,6 +914,8 @@ if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView if isTextViewSensitive(textView) { maskableWidgets.append(.init(view, in: window)) + // an unclipped subview can draw outside the text view's mask + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren) return } } @@ -978,6 +980,8 @@ if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead if isLabelSensitive(label) { maskableWidgets.append(.init(view, in: window)) + // an unclipped subview can draw outside the label's mask + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren) return } } diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index 966e00579..ee160426d 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -463,6 +463,33 @@ #expect(isCovered(frame, by: sut.integration.collectMaskableRects(in: window))) } + @Test("an image overflowing text masked for its attachment is still masked", arguments: ["label", "textView"]) + func imageOverflowingAttachmentText(kind: String) { + let sut = makeSut(maskAllTextInputs: false, maskAllImages: true) + defer { teardown(sut) } + + let text = attributedText(with: NSTextAttachment(image: makePhoto(size: CGSize(width: 24, height: 24)))) + let frame = CGRect(x: 24, y: 120, width: 200, height: 40) + let view: UIView + switch kind { + case "label": + let label = UILabel(frame: frame) + label.attributedText = text + view = label + default: + let textView = UITextView(frame: frame) + textView.attributedText = text + view = textView + } + view.clipsToBounds = false + let photo = UIImageView(frame: CGRect(x: 0, y: 60, width: 80, height: 80)) + photo.image = makePhoto(size: CGSize(width: 80, height: 80)) + view.addSubview(photo) + let window = makeWindow(containing: view) + + #expect(isCovered(photo.toAbsoluteRect(window), by: sut.integration.collectMaskableRects(in: window))) + } + @Test("a symbol attachment in text stays visible when only images are masked") func symbolAttachmentInLabel() throws { let sut = makeSut(maskAllTextInputs: false, maskAllImages: true) From 03ec5667fcc1ce74a53724d02791ffe3080bc74f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 11:16:19 -0400 Subject: [PATCH 3/3] fix(replay): carry ph-no-capture to subviews of masked labels and text views Co-Authored-By: Claude Opus 5.5 --- PostHog/Replay/PostHogReplayIntegration.swift | 4 +-- PostHogTests/PostHogMaskFailClosedTest.swift | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index b2a937dec..bb4c160c2 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -915,7 +915,7 @@ if isTextViewSensitive(textView) { maskableWidgets.append(.init(view, in: window)) // an unclipped subview can draw outside the text view's mask - findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren) + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture()) return } } @@ -981,7 +981,7 @@ if isLabelSensitive(label) { maskableWidgets.append(.init(view, in: window)) // an unclipped subview can draw outside the label's mask - findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren) + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture()) return } } diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index ee160426d..1956cd3ea 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -490,6 +490,32 @@ #expect(isCovered(photo.toAbsoluteRect(window), by: sut.integration.collectMaskableRects(in: window))) } + @Test("a plain subview overflowing no-capture text is masked", arguments: ["label", "textView"]) + func plainSubviewOverflowingNoCaptureText(kind: String) { + let sut = makeSut(maskAllTextInputs: false, maskAllImages: false) + defer { teardown(sut) } + + let frame = CGRect(x: 24, y: 120, width: 200, height: 40) + let view: UIView + switch kind { + case "label": + let label = UILabel(frame: frame) + label.text = "Sensitive" + view = label + default: + let textView = UITextView(frame: frame) + textView.text = "Sensitive" + view = textView + } + view.accessibilityIdentifier = "ph-no-capture" + view.clipsToBounds = false + let overflow = CustomDrawnSecretView(frame: CGRect(x: 0, y: 60, width: 80, height: 32)) + view.addSubview(overflow) + let window = makeWindow(containing: view) + + #expect(isCovered(overflow.toAbsoluteRect(window), by: sut.integration.collectMaskableRects(in: window))) + } + @Test("a symbol attachment in text stays visible when only images are masked") func symbolAttachmentInLabel() throws { let sut = makeSut(maskAllTextInputs: false, maskAllImages: true)