Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions .changeset/fuzzy-dogs-paste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog-js': patch
'@posthog/types': patch
---

Capture paste interactions with clipboard autocapture without collecting pasted text.
62 changes: 62 additions & 0 deletions packages/browser/src/__tests__/autocapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})

Expand All @@ -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,
Expand Down
34 changes: 20 additions & 14 deletions packages/browser/src/autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 DOM allowlist rejects paste events

When capture_copied_text is enabled alongside any dom_event_allowlist, shouldCaptureDomEvent rejects paste because the public allowlist supports only click, change, and submit, causing paste interactions to be silently omitted.

Knowledge Base Used: Browser event capture and autocapture

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browser/src/autocapture.ts
Line: 515

Comment:
**DOM allowlist rejects paste events**

When `capture_copied_text` is enabled alongside any `dom_event_allowlist`, `shouldCaptureDomEvent` rejects paste because the public allowlist supports only `click`, `change`, and `submit`, causing paste interactions to be silently omitted.

**Knowledge Base Used:** [Browser event capture and autocapture](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-js/-/docs/browser-event-capture.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

{ config: { get_current_url: config.getCurrentUrl } }
)
) {
Expand All @@ -524,14 +527,17 @@ 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'

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
}

Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/posthog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading