diff --git a/.changeset/fuzzy-dogs-paste.md b/.changeset/fuzzy-dogs-paste.md new file mode 100644 index 0000000000..8f8ff9675f --- /dev/null +++ b/.changeset/fuzzy-dogs-paste.md @@ -0,0 +1,6 @@ +--- +'posthog-js': patch +'@posthog/types': patch +--- + +Capture paste interactions with clipboard autocapture without collecting pasted text. diff --git a/packages/browser/src/__tests__/autocapture.test.ts b/packages/browser/src/__tests__/autocapture.test.ts index c4463aca8f..e920baf1a1 100644 --- a/packages/browser/src/__tests__/autocapture.test.ts +++ b/packages/browser/src/__tests__/autocapture.test.ts @@ -878,6 +878,7 @@ describe('Autocapture system', () => { const mockCall = beforeSendMock.mock.calls[0][0] expect(mockCall.event).toEqual('$copy_autocapture') expect(mockCall.properties).toHaveProperty('$selected_content', 'copy this test') + expect(mockCall.properties).toHaveProperty('$clipboard_text_length', 14) expect(mockCall.properties).toHaveProperty('$copy_type', 'copy') }) @@ -894,9 +895,70 @@ describe('Autocapture system', () => { expect(spyArgs.length).toBe(1) expect(spyArgs[0][0].event).toEqual('$copy_autocapture') expect(spyArgs[0][0].properties).toHaveProperty('$selected_content', 'cut this test') + expect(spyArgs[0][0].properties).toHaveProperty('$clipboard_text_length', 13) expect(spyArgs[0][0].properties).toHaveProperty('$copy_type', 'cut') }) + it('captures paste without reading pasted text', async () => { + const pasteBeforeSend = jest.fn().mockImplementation((event) => event) + const pastePosthog = await createPosthogInstance(uuidv7(), { + autocapture: { capture_copied_text: true, dom_event_allowlist: ['click'] }, + before_send: pasteBeforeSend, + }) + + try { + document.body.appendChild(elTarget) + const pasteEvent = new Event('paste', { bubbles: true, cancelable: true }) + const getData = jest.fn().mockReturnValue('paste this test') + Object.defineProperty(pasteEvent, 'clipboardData', { value: { getData } }) + + elTarget.dispatchEvent(pasteEvent) + + expect(getData).not.toHaveBeenCalled() + expect(pasteBeforeSend).toHaveBeenCalledTimes(1) + const captured = pasteBeforeSend.mock.calls[0][0] + expect(captured.event).toEqual('$copy_autocapture') + expect(captured.properties).toHaveProperty('$copy_type', 'paste') + expect(captured.properties).not.toHaveProperty('$clipboard_text_length') + expect(captured.properties).not.toHaveProperty('$selected_content') + } finally { + await pastePosthog.shutdown() + } + }) + + it('captures paste from sensitive fields and ancestors without content', async () => { + const pasteBeforeSend = jest.fn().mockImplementation((event) => event) + const pastePosthog = await createPosthogInstance(uuidv7(), { + autocapture: { capture_copied_text: true }, + before_send: pasteBeforeSend, + }) + const container = document.createElement('div') + const passwordInput = document.createElement('input') + passwordInput.type = 'password' + const sensitiveParent = document.createElement('div') + sensitiveParent.className = 'ph-sensitive' + const sensitiveInput = document.createElement('input') + sensitiveParent.appendChild(sensitiveInput) + container.append(passwordInput, sensitiveParent) + document.body.appendChild(container) + + try { + passwordInput.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true })) + sensitiveInput.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true })) + + expect(pasteBeforeSend).toHaveBeenCalledTimes(2) + for (const [captured] of pasteBeforeSend.mock.calls) { + expect(captured.event).toEqual('$copy_autocapture') + expect(captured.properties).toHaveProperty('$copy_type', 'paste') + expect(captured.properties).not.toHaveProperty('$clipboard_text_length') + expect(captured.properties).not.toHaveProperty('$selected_content') + } + } finally { + container.remove() + await pastePosthog.shutdown() + } + }) + it('ignores empty selection', () => { const fakeEvent = makeCopyEvent({ target: elTarget, diff --git a/packages/browser/src/autocapture.ts b/packages/browser/src/autocapture.ts index 6ed787831d..dca03ef542 100644 --- a/packages/browser/src/autocapture.ts +++ b/packages/browser/src/autocapture.ts @@ -365,12 +365,13 @@ export class Autocapture implements Extension { try { this._captureEvent(e, COPY_AUTOCAPTURE_EVENT) } catch (error) { - logger.error('Failed to capture copy/cut event', error) + logger.error('Failed to capture clipboard event', error) } }) addEventListener(document, 'copy', copiedTextHandler, { capture: true }) addEventListener(document, 'cut', copiedTextHandler, { capture: true }) + addEventListener(document, 'paste', copiedTextHandler, { capture: true }) } } @@ -384,6 +385,7 @@ export class Autocapture implements Extension { if (this._copiedTextHandler) { document?.removeEventListener('copy', this._copiedTextHandler, true) document?.removeEventListener('cut', this._copiedTextHandler, true) + document?.removeEventListener('paste', this._copiedTextHandler, true) this._copiedTextHandler = undefined } this._initialized = false @@ -489,19 +491,20 @@ export class Autocapture implements Extension { } } - const isCopyAutocapture = eventName === COPY_AUTOCAPTURE_EVENT + const isClipboardAutocapture = eventName === COPY_AUTOCAPTURE_EVENT + const eventConfig = isClipboardAutocapture ? { ...config, dom_event_allowlist: undefined } : config if ( target && shouldCaptureDomEvent( target, e, - config, - // mostly this method cares about the target element, but in the case of copy events, + eventConfig, + // mostly this method cares about the target element, but for clipboard events, // we want some of the work this check does without insisting on the target element's type - isCopyAutocapture, - // we also don't want to restrict copy checks to clicks, + isClipboardAutocapture, + // we also don't want to restrict clipboard checks to clicks, // so we pass that knowledge in here, rather than add the logic inside the check - isCopyAutocapture ? ['copy', 'cut'] : undefined, + isClipboardAutocapture ? ['copy', 'cut', 'paste'] : undefined, { config: { get_current_url: config.getCurrentUrl } } ) ) { @@ -524,14 +527,18 @@ export class Autocapture implements Extension { } if (eventName === COPY_AUTOCAPTURE_EVENT) { - // you can't read the data from the clipboard event, - // but you can guess that you can read it from the window's current selection - const selectedContent = makeSafeText(window?.getSelection()?.toString()) - const clipType = (e as ClipboardEvent).type || 'clipboard' - if (!selectedContent) { - return false + const clipType = e.type || 'clipboard' + + // Don't add the contents for paste events, as usually the page emits an input change with the new value + if (clipType !== 'paste') { + const selectedText = window?.getSelection()?.toString() + const selectedContent = makeSafeText(selectedText) + if (!selectedContent) { + return false + } + props['$selected_content'] = selectedContent + props['$clipboard_text_length'] = selectedText?.length ?? 0 } - props['$selected_content'] = selectedContent props['$copy_type'] = clipType } diff --git a/packages/types/src/posthog-config.ts b/packages/types/src/posthog-config.ts index 05e089b102..894852dae2 100644 --- a/packages/types/src/posthog-config.ts +++ b/packages/types/src/posthog-config.ts @@ -96,7 +96,7 @@ export interface AutocaptureConfig { element_attribute_ignorelist?: string[] /** - * When set to true, autocapture will capture the text of any element that is cut or copied. + * When true, autocapture captures cut, copy, and paste interactions. Paste events do not contain pasted text. */ capture_copied_text?: boolean }