feat: Ensure password inputs are masked when switching type (#1170)

* feat: Ensure password inputs are masked when switching type


Apply formatting changes

use data- attribute


ref: Ensure type is always lowercased


add changeset

* extract into util

* Apply formatting changes
This commit is contained in:
Francesco Novy
2023-03-20 13:13:23 +01:00
committed by GitHub
parent a82a3b42b1
commit d2582e9a81
9 changed files with 519 additions and 382 deletions

View File

@@ -0,0 +1,6 @@
---
'rrweb-snapshot': minor
'rrweb': minor
---
feat: Ensure password inputs remain masked when switching input type

View File

@@ -674,8 +674,13 @@ function serializeElementNode(
attributes.type !== 'button' &&
value
) {
const type: string | null = n.hasAttribute('data-rr-is-password')
? 'password'
: typeof attributes.type === 'string'
? attributes.type.toLowerCase()
: null;
attributes.value = maskInputValue({
type: attributes.type,
type,
tagName,
value,
maskInputOptions,

View File

@@ -162,14 +162,16 @@ export function maskInputValue({
}: {
maskInputOptions: MaskInputOptions;
tagName: string;
type: string | number | boolean | null;
type: string | null;
value: string | null;
maskInputFn?: MaskInputFn;
}): string {
let text = value || '';
const actualType = type && type.toLowerCase();
if (
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
maskInputOptions[type as keyof MaskInputOptions]
(actualType && maskInputOptions[actualType as keyof MaskInputOptions])
) {
if (maskInputFn) {
text = maskInputFn(text);

View File

@@ -29,6 +29,7 @@ import {
isSerializedStylesheet,
inDom,
getShadowHost,
getInputType,
} from '../utils';
type DoubleLinkedListNode = {
@@ -488,11 +489,14 @@ export default class MutationBuffer {
const target = m.target as HTMLElement;
let attributeName = m.attributeName as string;
let value = (m.target as HTMLElement).getAttribute(attributeName);
if (attributeName === 'value') {
const type = getInputType(target);
value = maskInputValue({
maskInputOptions: this.maskInputOptions,
tagName: (m.target as HTMLElement).tagName,
type: (m.target as HTMLElement).getAttribute('type'),
tagName: target.tagName,
type,
value,
maskInputFn: this.maskInputFn,
});
@@ -527,6 +531,17 @@ export default class MutationBuffer {
};
this.attributes.push(item);
}
// Keep this property on inputs that used to be password inputs
// This is used to ensure we do not unmask value when using e.g. a "Show password" type button
if (
attributeName === 'type' &&
target.tagName === 'INPUT' &&
(m.oldValue || '').toLowerCase() === 'password'
) {
target.setAttribute('data-rr-is-password', 'true');
}
if (attributeName === 'style') {
const old = this.doc.createElement('span');
if (m.oldValue) {

View File

@@ -4,6 +4,7 @@ import {
throttle,
on,
hookSetter,
getInputType,
getWindowScroll,
getWindowHeight,
getWindowWidth,
@@ -338,39 +339,42 @@ function initInputObserver({
userTriggeredOnInput,
}: observerParam): listenerHandler {
function eventHandler(event: Event) {
let target = getEventTarget(event);
let target = getEventTarget(event) as HTMLElement | null;
const userTriggered = event.isTrusted;
const tagName = target && target.tagName;
/**
* If a site changes the value 'selected' of an option element, the value of its parent element, usually a select element, will be changed as well.
* We can treat this change as a value change of the select element the current target belongs to.
*/
if (target && (target as Element).tagName === 'OPTION')
target = (target as Element).parentElement;
if (target && tagName === 'OPTION') {
target = target.parentElement;
}
if (
!target ||
!(target as Element).tagName ||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
!tagName ||
INPUT_TAGS.indexOf(tagName) < 0 ||
isBlocked(target as Node, blockClass, blockSelector, true)
) {
return;
}
const type: string | undefined = (target as HTMLInputElement).type;
if ((target as HTMLElement).classList.contains(ignoreClass)) {
if (target.classList.contains(ignoreClass)) {
return;
}
let text = (target as HTMLInputElement).value;
let isChecked = false;
const type: Lowercase<string> = getInputType(target) || '';
if (type === 'radio' || type === 'checkbox') {
isChecked = (target as HTMLInputElement).checked;
} else if (
maskInputOptions[
(target as Element).tagName.toLowerCase() as keyof MaskInputOptions
] ||
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
maskInputOptions[type as keyof MaskInputOptions]
) {
text = maskInputValue({
maskInputOptions,
tagName: (target as HTMLElement).tagName,
tagName,
type,
value: text,
maskInputFn,

View File

@@ -563,3 +563,17 @@ export function inDom(n: Node): boolean {
if (!doc) return false;
return doc.contains(n) || shadowHostInDom(n);
}
/**
* Get the type of an input element.
* This takes care of the case where a password input is changed to a text input.
* In this case, we continue to consider this of type password, in order to avoid leaking sensitive data
* where passwords should be masked.
*/
export function getInputType(element: HTMLElement): Lowercase<string> | null {
return element.hasAttribute('data-rr-is-password')
? 'password'
: element.hasAttribute('type')
? (element.getAttribute('type')!.toLowerCase() as Lowercase<string>)
: null;
}

View File

@@ -5102,6 +5102,451 @@ exports[`record integration tests should handle recursive console messages 1`] =
]"
`;
exports[`record integration tests should mask password value attribute with maskInputOptions 1`] = `
"[
{
\\"type\\": 0,
\\"data\\": {}
},
{
\\"type\\": 1,
\\"data\\": {}
},
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"http-equiv\\": \\"X-UA-Compatible\\",
\\"content\\": \\"IE=edge\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 11
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Document\\",
\\"id\\": 13
}
],
\\"id\\": 12
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 17
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"password\\",
\\"id\\": \\"password\\"
},
\\"childNodes\\": [],
\\"id\\": 18
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 19
},
{
\\"type\\": 2,
\\"tagName\\": \\"button\\",
\\"attributes\\": {
\\"type\\": \\"button\\",
\\"id\\": \\"show-password\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Toggle show password\\",
\\"id\\": 21
}
],
\\"id\\": 20
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 22
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 24
}
],
\\"id\\": 23
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 25
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 27
}
],
\\"id\\": 26
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 28
}
],
\\"id\\": 16
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"*\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"**\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"***\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"****\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"*****\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"******\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 1,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 6,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 0,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 2,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"type\\": \\"text\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"data-rr-is-password\\": \\"true\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 6,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"*******\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"********\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 1,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 6,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 0,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 2,
\\"id\\": 20
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"type\\": \\"password\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
}
]"
`;
exports[`record integration tests should mask texts 1`] = `
"[
{
@@ -5658,367 +6103,6 @@ exports[`record integration tests should mask texts using maskTextFn 1`] = `
]"
`;
exports[`record integration tests should mask value attribute with maskInputOptions 1`] = `
"[
{
\\"type\\": 0,
\\"data\\": {}
},
{
\\"type\\": 1,
\\"data\\": {}
},
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"http-equiv\\": \\"X-UA-Compatible\\",
\\"content\\": \\"IE=edge\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 11
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Document\\",
\\"id\\": 13
}
],
\\"id\\": 12
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 17
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"password\\",
\\"id\\": \\"password\\"
},
\\"childNodes\\": [],
\\"id\\": 18
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 19
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 21
}
],
\\"id\\": 20
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 22
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 24
}
],
\\"id\\": 23
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 25
}
],
\\"id\\": 16
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"*\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"value\\": \\"*\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"**\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"value\\": \\"**\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"***\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"value\\": \\"***\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"****\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"value\\": \\"****\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"*****\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"value\\": \\"*****\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"******\\",
\\"isChecked\\": false,
\\"id\\": 18
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"value\\": \\"******\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
}
]"
`;
exports[`record integration tests should nest record iframe 1`] = `
"[
{

View File

@@ -8,11 +8,13 @@
</head>
<body>
<input type="password" id="password" />
<button type="button" id="show-password">Toggle show password</button>
<script>
const password = document.getElementById('password');
password.addEventListener('keyup', (event) => {
password.setAttribute('value', password.value);
document.getElementById('show-password').addEventListener('click', function() {
password.setAttribute('type', password.getAttribute('type') === 'password' ? 'text' : 'password');
});
</script>
</script>
</body>
</html>

View File

@@ -281,7 +281,7 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots);
});
it('should mask value attribute with maskInputOptions', async () => {
it('should mask password value attribute with maskInputOptions', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(
@@ -292,7 +292,12 @@ describe('record integration tests', function (this: ISuite) {
}),
);
await page.type('input[type="password"]', 'secr3t');
await page.type('#password', 'secr3t');
// Change type to text (simulate "show password")
await page.click('#show-password');
await page.type('#password', 'XY');
await page.click('#show-password');
const snapshots = (await page.evaluate(
'window.snapshots',