Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selective-unmask-sensitive-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog_flutter": minor
---

Add `PostHogUnmaskWidget` to selectively reveal known-safe Flutter text and images while keeping global session replay masking enabled. Explicit masks and sensitive inputs take precedence regardless of nesting. Password, card number/security code/expiration date (including day/month/year), and one-time-code autofill hints, password keyboard types, and obscured fields now stay masked across Material, Cupertino, and direct `EditableText` inputs even when global text masking is disabled. Flutter web still requires canvas masking to be enabled; native platform views and captured native screens are unaffected.
65 changes: 65 additions & 0 deletions api/posthog_flutter.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -5266,6 +5266,71 @@
],
"typeParameterNames": []
},
{
"entryPoints": [
"posthog_flutter.dart"
],
"executableDeclarations": [
{
"entryPoints": [],
"isDeprecated": false,
"isExperimental": false,
"isStatic": false,
"name": "new",
"parameters": [
{
"isDeprecated": false,
"isExperimental": false,
"isNamed": true,
"isRequired": false,
"name": "key",
"relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart",
"typeName": "Key?"
},
{
"isDeprecated": false,
"isExperimental": false,
"isNamed": true,
"isRequired": true,
"name": "child",
"relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart",
"typeName": "Widget"
}
],
"relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart",
"returnTypeName": "PostHogUnmaskWidget",
"type": "constructor",
"typeParameterNames": []
}
],
"fieldDeclarations": [
{
"entryPoints": [],
"isDeprecated": false,
"isExperimental": false,
"isReadable": true,
"isStatic": false,
"isWriteable": false,
"name": "child",
"relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart",
"typeName": "Widget"
}
],
"isDeprecated": false,
"isExperimental": false,
"isRequired": false,
"isSealed": false,
"name": "PostHogUnmaskWidget",
"relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart",
"superTypeNames": [
"StatelessWidget",
"Widget",
"DiagnosticableTree",
"Object",
"Diagnosticable"
],
"typeParameterNames": []
},
{
"entryPoints": [
"posthog_flutter.dart"
Expand Down
1 change: 1 addition & 0 deletions posthog_flutter/lib/posthog_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export 'src/posthog_event.dart';
export 'src/posthog_observer.dart';
export 'src/posthog_widget.dart';
export 'src/replay/mask/posthog_mask_widget.dart';
export 'src/replay/mask/posthog_unmask_widget.dart';
export 'src/replay/mask/posthog_platform_view.dart';
12 changes: 10 additions & 2 deletions posthog_flutter/lib/src/posthog_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -682,15 +682,23 @@ class PostHogSessionReplayConfig {
PostHogSessionReplayConfig();

/// Enable masking of all text and text input fields.
/// Default: true.
/// Default: true. Wrap known-safe Flutter content in `PostHogUnmaskWidget`
/// to reveal it without disabling masking globally.
///
/// Sensitive Flutter inputs stay masked regardless of this flag or unmask
/// widgets: `obscureText`, `TextInputType.visiblePassword`, and autofill hints
/// for passwords, new passwords, credit card numbers/security codes,
/// expiration dates (including day/month/year), and one-time codes.
/// Explicit `PostHogMaskWidget` masks also always apply.
///
/// With [captureNativeScreens] enabled, setting this false also unmasks text
/// on captured native screens, including native input fields (passwords,
/// card numbers) you may not have built.
var maskAllTexts = true;

/// Enable masking of all images.
/// Default: true.
/// Default: true. `PostHogUnmaskWidget` can reveal known-safe Flutter images;
/// explicit `PostHogMaskWidget` masks still take precedence.
var maskAllImages = true;

/// Deprecated setter that forwards assigned values to [throttleDelay].
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import 'package:flutter/material.dart';
import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart';
import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart';

class ElementData {
Rect rect;
String type;
List<ElementData>? children;
Widget? widget;
Matrix4? transform;
bool isSensitiveText;

ElementData({
required this.rect,
required this.type,
this.children,
this.widget,
this.transform,
this.isSensitiveText = false,
});

void addChildren(ElementData elementData) {
Expand Down Expand Up @@ -43,13 +46,10 @@ class ElementData {

void _collectMaskWidgetElements(
ElementData element, List<ElementData> elements) {
if (element.widget is PostHogMaskWidget) {
if (element.widget is PostHogMaskWidget ||
element.isSensitiveText ||
isSensitiveTextInput(element.widget)) {
elements.add(element);
} else if (element.widget is TextField) {
final textField = element.widget as TextField;
if (textField.obscureText) {
elements.add(element);
}
}

final children = element.children;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ import 'package:flutter/rendering.dart';
import 'package:posthog_flutter/posthog_flutter.dart';
import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart';
import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart';
import 'package:posthog_flutter/src/replay/element_parsers/render_editable_parser.dart';
import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart';
import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart';

class ElementObjectParser {
final ElementParser _elementParser = ElementParser();
final RenderEditableParser _renderEditableParser = RenderEditableParser();

ElementData? relateRenderObject(
ElementData activeElementData,
Element element,
) {
if (element.widget is PostHogMaskWidget) {
Element element, {
bool unmask = false,
bool sensitiveText = false,
}) {
if (element.widget is PostHogMaskWidget ||
isSensitiveTextInput(element.widget)) {
final elementData = _elementParser.relate(element);

if (elementData != null) {
Expand All @@ -21,40 +27,26 @@ class ElementObjectParser {
}
}

if (element.widget is Text) {
final config = Posthog().config?.sessionReplayConfig;
final maskAllTexts = config?.maskAllTexts ?? true;

if (maskAllTexts) {
final elementData = _elementParser.relate(element);

if (elementData != null) {
activeElementData.addChildren(elementData);
return elementData;
}
// Dense/scaled inputs can paint beyond their widget bounds. Preserve the
// RenderEditable mask as part of the sensitivity floor, even when unmasked.
if (sensitiveText &&
element is RenderObjectElement &&
element.renderObject is RenderEditable) {
final elementData = _renderEditableParser.relate(element);
if (elementData != null) {
elementData.isSensitiveText = true;
activeElementData.addChildren(elementData);
return elementData;
}
}

// Handle TextField and TextFormField masking
// Only mask at widget level for obscureText fields when maskAllTexts is false
// When maskAllTexts is true, RenderEditable detection will handle it with better bounds
if (element.widget is TextField || element.widget is TextFormField) {
if (unmask) return null;

if (element.widget is Text) {
final config = Posthog().config?.sessionReplayConfig;
final maskAllTexts = config?.maskAllTexts ?? true;

var isObscured = false;
if (element.widget is TextField) {
isObscured = (element.widget as TextField).obscureText;
}

// Note: TextFormField obscureText is handled differently in Flutter.
// TextFormField creates an internal TextField, but the obscureText property
// is not directly accessible on the TextFormField widget itself.
// For TextFormField, we rely on the maskAllTexts configuration.
// Otherwise, let RenderEditable handle it (it has better bounds via preferredLineHeight)
final shouldMask = !maskAllTexts && isObscured;

if (shouldMask) {
if (maskAllTexts) {
final elementData = _elementParser.relate(element);

if (elementData != null) {
Expand All @@ -64,6 +56,10 @@ class ElementObjectParser {
}
}

// Component elements can forward a descendant's render object before an
// intervening unmask widget has been visited. Match only its owning element.
if (element is! RenderObjectElement) return null;

if (element.renderObject is RenderImage) {
final dataType = element.renderObject.runtimeType.toString();

Expand Down
29 changes: 29 additions & 0 deletions posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:flutter/widgets.dart';

/// Reveals a widget subtree in session replay despite global text/image masking.
///
/// Keep `maskAllTexts` / `maskAllImages` enabled and reveal only known-safe UI:
///
/// ```dart
/// PostHogUnmaskWidget(child: Text('Try again'))
/// ```
///
/// Only wrap content known to be safe. Explicit `PostHogMaskWidget` masks and
/// sensitive text inputs always take precedence, regardless of nesting order.
/// This does not erase masks from ancestors or overlapping widgets, reveal
/// native platform views, or change masking on captured native screens.
///
/// On Flutter web, canvas masking must already be enabled through
/// `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`, or by
/// mounting a `PostHogMaskWidget`. This widget does not enable canvas recording
/// or masking itself. Keep it inside `PostHogWidget` on all platforms.
class PostHogUnmaskWidget extends StatelessWidget {
/// The known-safe widget subtree to reveal in session replay.
final Widget child;

/// Creates an exception to global text/image masking around [child].
const PostHogUnmaskWidget({super.key, required this.child});

@override
Widget build(BuildContext context) => child;
}
41 changes: 41 additions & 0 deletions posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

const _sensitiveAutofillHints = {
AutofillHints.password,
AutofillHints.newPassword,
AutofillHints.creditCardNumber,
AutofillHints.creditCardSecurityCode,
AutofillHints.creditCardExpirationDate,
AutofillHints.creditCardExpirationDay,
AutofillHints.creditCardExpirationMonth,
AutofillHints.creditCardExpirationYear,
AutofillHints.oneTimeCode,
Comment thread
veria-ai[bot] marked this conversation as resolved.
};

bool isSensitiveTextInput(Widget? widget) {
final bool obscureText;
final TextInputType? keyboardType;
final Iterable<String>? autofillHints;
if (widget is EditableText) {
obscureText = widget.obscureText;
keyboardType = widget.keyboardType;
autofillHints = widget.autofillHints;
} else if (widget is TextField) {
obscureText = widget.obscureText;
keyboardType = widget.keyboardType;
autofillHints = widget.autofillHints;
} else if (widget is CupertinoTextField) {
// Cupertino passes autofill hints through its AutofillClient, not through
// the nested EditableText's autofillHints.
obscureText = widget.obscureText;
keyboardType = widget.keyboardType;
autofillHints = widget.autofillHints;
} else {
return false;
}

return obscureText ||
keyboardType == TextInputType.visiblePassword ||
(autofillHints?.any(_sensitiveAutofillHints.contains) ?? false);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart';
import 'package:posthog_flutter/src/replay/element_parsers/element_data_factory.dart';
import 'package:posthog_flutter/src/replay/element_parsers/element_object_parser.dart';
import 'package:posthog_flutter/src/replay/element_parsers/root_element_provider.dart';
import 'package:posthog_flutter/src/replay/mask/posthog_unmask_widget.dart';
import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart';

class WidgetElementsDecipher {
late ElementData _rootElementData;
Expand Down Expand Up @@ -36,14 +38,21 @@ class WidgetElementsDecipher {
return _rootElementData;
}

void _parseAllElements(ElementData activeElementData, Element element) {
void _parseAllElements(ElementData activeElementData, Element element,
{bool unmask = false, bool sensitiveText = false}) {
final unmaskSubtree = unmask || element.widget is PostHogUnmaskWidget;
final sensitiveSubtree =
sensitiveText || isSensitiveTextInput(element.widget);
ElementData? newElementData = _elementObjectParser.relateRenderObject(
activeElementData,
element,
unmask: unmaskSubtree,
sensitiveText: sensitiveSubtree,
);

element.debugVisitOnstageChildren((childElement) {
_parseAllElements(newElementData ?? activeElementData, childElement);
_parseAllElements(newElementData ?? activeElementData, childElement,
unmask: unmaskSubtree, sensitiveText: sensitiveSubtree);
});
}
}
Loading