Skip to content
Merged
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/moody-foxes-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---

prevent infinite recursion bug with 3rd party proxy on posthog
56 changes: 56 additions & 0 deletions packages/browser/src/__tests__/posthog-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,4 +483,60 @@ describe('posthog core', () => {
})
})
})

describe('_execute_array and push re-entrancy guard', () => {
it('should not infinitely recurse when push is called re-entrantly (e.g., TikTok Proxy)', () => {
const posthog = defaultPostHog()

// Simulate TikTok's in-app browser Proxy behavior:
// When _execute_array dispatches a method via this[method](),
// a Proxy intercepts it and calls push() instead, which would
// re-enter _execute_array and cause infinite recursion.
const origCapture = posthog.capture.bind(posthog)
let callCount = 0
posthog.capture = function (...args: any[]) {
callCount++
if (callCount > 100) {
throw new Error('Infinite recursion detected')
}
// Simulate what TikTok's Proxy does: convert the method call
// to a push() call
posthog.push(['capture', ...args])
} as any

// This should not throw RangeError: Maximum call stack size exceeded
expect(() => {
posthog.push(['capture', 'test-event', { foo: 'bar' }])
}).not.toThrow()

// Restore original capture to verify it was called via prototype
posthog.capture = origCapture
})

it('should execute methods normally when no Proxy interference', () => {
const posthog = defaultPostHog()
const captureSpy = jest.spyOn(posthog, 'capture').mockImplementation()

posthog.push(['capture', 'test-event', { foo: 'bar' }])

expect(captureSpy).toHaveBeenCalledWith('test-event', { foo: 'bar' })
captureSpy.mockRestore()
})

it('should handle _execute_array with array of commands', () => {
const posthog = defaultPostHog()
const registerSpy = jest.spyOn(posthog, 'register').mockImplementation()
const captureSpy = jest.spyOn(posthog, 'capture').mockImplementation()

posthog._execute_array([
['register', { key: 'value' }],
['capture', 'test-event'],
])

expect(registerSpy).toHaveBeenCalledWith({ key: 'value' })
expect(captureSpy).toHaveBeenCalledWith('test-event')
registerSpy.mockRestore()
captureSpy.mockRestore()
})
})
})
107 changes: 67 additions & 40 deletions packages/browser/src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ type OnlyValidKeys<T, Shape> = T extends Shape ? (Exclude<keyof T, keyof Shape>

const instances: Record<string, PostHog> = {}

// Tracks re-entrant calls to _execute_array. Used to detect when a third-party
// Proxy (e.g., TikTok's in-app browser) wraps window.posthog and converts method
// calls into push() calls, which would otherwise cause infinite recursion.
let _executeArrayDepth = 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Critical bug: _executeArrayDepth is a module-level global variable shared across all PostHog instances. This causes incorrect behavior when multiple instances exist.

Scenario that breaks:

  1. Instance A calls _execute_array()_executeArrayDepth = 1
  2. During execution, Instance B's push() is called normally (no proxy)
  3. Instance B sees _executeArrayDepth > 0 and incorrectly assumes it's a re-entrant call
  4. Instance B bypasses _execute_array() and calls the method directly, skipping the normal queuing/ordering logic

Fix: Move _executeArrayDepth to be an instance variable:

class PostHog {
    private _executeArrayDepth: number = 0
    // ... rest of class
}

Then update references from _executeArrayDepth to this._executeArrayDepth in both _execute_array() and push() methods.

Suggested change
let _executeArrayDepth = 0
// This variable is now moved to be an instance property of the PostHog class

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


// some globals for comparisons
const __NOOP = () => {}

Expand Down Expand Up @@ -999,50 +1004,59 @@ export class PostHog implements PostHogInterface {
* @param {Array} array
*/
_execute_array(array: SnippetArrayItem[]): void {
let fn_name
const alias_calls: SnippetArrayItem[] = []
const other_calls: SnippetArrayItem[] = []
const capturing_calls: SnippetArrayItem[] = []
eachArray(array, (item) => {
if (item) {
fn_name = item[0]
if (isArray(fn_name)) {
capturing_calls.push(item) // chained call e.g. posthog.get_group().set()
} else if (isFunction(item)) {
;(item as any).call(this)
} else if (isArray(item) && fn_name === 'alias') {
alias_calls.push(item)
} else if (isArray(item) && fn_name.indexOf('capture') !== -1 && isFunction((this as any)[fn_name])) {
capturing_calls.push(item)
} else {
other_calls.push(item)
_executeArrayDepth++
try {
let fn_name
const alias_calls: SnippetArrayItem[] = []
const other_calls: SnippetArrayItem[] = []
const capturing_calls: SnippetArrayItem[] = []
eachArray(array, (item) => {
if (item) {
fn_name = item[0]
if (isArray(fn_name)) {
capturing_calls.push(item) // chained call e.g. posthog.get_group().set()
} else if (isFunction(item)) {
;(item as any).call(this)
} else if (isArray(item) && fn_name === 'alias') {
alias_calls.push(item)
} else if (
isArray(item) &&
fn_name.indexOf('capture') !== -1 &&
isFunction((this as any)[fn_name])
) {
capturing_calls.push(item)
} else {
other_calls.push(item)
}
}
})

const execute = function (calls: SnippetArrayItem[], thisArg: any) {
eachArray(
calls,
function (item) {
if (isArray(item[0])) {
// chained call
let caller = thisArg
each(item, function (call) {
caller = caller[call[0]].apply(caller, call.slice(1))
})
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this[item[0]].apply(this, item.slice(1))
}
},
thisArg
)
}
})

const execute = function (calls: SnippetArrayItem[], thisArg: any) {
eachArray(
calls,
function (item) {
if (isArray(item[0])) {
// chained call
let caller = thisArg
each(item, function (call) {
caller = caller[call[0]].apply(caller, call.slice(1))
})
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this[item[0]].apply(this, item.slice(1))
}
},
thisArg
)
execute(alias_calls, this)
execute(other_calls, this)
execute(capturing_calls, this)
} finally {
_executeArrayDepth--
}

execute(alias_calls, this)
execute(other_calls, this)
execute(capturing_calls, this)
}

_hasBootstrappedFeatureFlags(): boolean {
Expand All @@ -1067,6 +1081,19 @@ export class PostHog implements PostHogInterface {
* @param {Array} item A [function_name, args...] array to be executed
*/
push(item: SnippetArrayItem): void {
if (_executeArrayDepth > 0 && isArray(item) && isString(item[0])) {
// push() is being called while _execute_array is already running.
// This happens when a third-party Proxy (e.g., TikTok's in-app browser)
// wraps window.posthog and converts method calls into push() calls,
// creating an infinite loop: _execute_array -> this[method] -> Proxy ->
// push -> _execute_array -> ...
// Dispatch directly from the prototype to break the cycle.
const fn = (PostHog.prototype as any)[item[0]]
if (isFunction(fn)) {
fn.apply(this, item.slice(1))
}
return
}
this._execute_array([item])
}

Expand Down
Loading
Loading