fix(sdk): prevent infinite recursion with proxy#3112
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
aa2a63a to
19de717
Compare
| @@ -0,0 +1,263 @@ | |||
| /* eslint-disable no-console */ | |||
There was a problem hiding this comment.
will remove before merging; including for context
|
Size Change: +1.15 kB (+0.02%) Total Size: 6.02 MB
ℹ️ View Unchanged
|
| // 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 |
There was a problem hiding this comment.
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:
- Instance A calls
_execute_array()→_executeArrayDepth = 1 - During execution, Instance B's
push()is called normally (no proxy) - Instance B sees
_executeArrayDepth > 0and incorrectly assumes it's a re-entrant call - 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.
| let _executeArrayDepth = 0 | |
| // This variable is now moved to be an instance property of the PostHog class |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
| /* eslint-disable no-console */ | ||
| import { usePostHog } from 'posthog-js/react' | ||
| import { useState, useCallback } from 'react' | ||
|
|
||
| /** | ||
| * Simulates TikTok's in-app browser Proxy behavior. | ||
| * | ||
| * TikTok's WebView injects a script that wraps `window.posthog` with a | ||
| * JavaScript Proxy. The Proxy intercepts known analytics method calls | ||
| * (capture, identify, getFeatureFlag, isFeatureEnabled, has_opted_out_capturing, | ||
| * etc.) and converts them into `target.push([methodName, ...args])` calls. | ||
| * | ||
| * This creates an infinite recursion loop: | ||
| * _execute_array -> this[method] -> Proxy intercept -> push() -> | ||
| * _execute_array -> this[method] -> Proxy intercept -> push() -> ... | ||
| * | ||
| * The Proxy does NOT intercept internal/private methods like push, _execute_array, | ||
| * config, etc. — only well-known analytics API methods. | ||
| */ | ||
| function wrapWithTikTokProxy(): { interceptCount: number } { | ||
| const state = { interceptCount: 0 } | ||
|
|
||
| const target = (window as any).posthog | ||
| if (!target || target.__tiktokProxied) { | ||
| return state | ||
| } | ||
|
|
||
| // Methods that TikTok's Proxy intercepts (based on the stack traces) | ||
| const interceptedMethods = new Set([ | ||
| 'capture', | ||
| 'identify', | ||
| 'alias', | ||
| 'getFeatureFlag', | ||
| 'isFeatureEnabled', | ||
| 'getFeatureFlagPayload', | ||
| 'has_opted_out_capturing', | ||
| 'opt_out_capturing', | ||
| 'opt_in_capturing', | ||
| 'register', | ||
| 'register_once', | ||
| 'unregister', | ||
| 'set_config', | ||
| 'people', | ||
| ]) | ||
|
|
||
| const proxy = new Proxy(target, { | ||
| get(obj, prop, receiver) { | ||
| const value = Reflect.get(obj, prop, receiver) | ||
|
|
||
| // Only intercept known analytics methods | ||
| if (typeof prop === 'string' && interceptedMethods.has(prop) && typeof value === 'function') { | ||
| return function (this: any, ...args: any[]) { | ||
| state.interceptCount++ | ||
| console.log(`[TikTok Proxy] Intercepted: ${prop}(`, ...args, ')') | ||
| // This is what TikTok's Proxy does: convert the method call | ||
| // into a push() call, mimicking the pre-load snippet behavior | ||
| proxy.push([prop].concat(Array.prototype.slice.call(args, 0))) | ||
| } | ||
| } | ||
|
|
||
| return value | ||
| }, | ||
| }) | ||
|
|
||
| proxy.__tiktokProxied = true | ||
| ;(window as any).posthog = proxy | ||
|
|
||
| return state | ||
| } | ||
|
|
||
| export default function TikTokProxyPage() { | ||
| const posthog = usePostHog() | ||
| const [log, setLog] = useState<string[]>([]) | ||
| const [proxyActive, setProxyActive] = useState(false) | ||
| const [interceptCount, setInterceptCount] = useState(0) | ||
| const [proxyState, setProxyState] = useState<{ interceptCount: number } | null>(null) | ||
|
|
||
| const addLog = useCallback((msg: string) => { | ||
| setLog((prev) => [...prev, `[${new Date().toISOString().split('T')[1].split('.')[0]}] ${msg}`]) | ||
| }, []) | ||
|
|
||
| const enableProxy = useCallback(() => { | ||
| const state = wrapWithTikTokProxy() | ||
| setProxyState(state) | ||
| setProxyActive(true) | ||
| addLog('TikTok Proxy enabled — window.posthog is now wrapped') | ||
| }, [addLog]) | ||
|
|
||
| const disableProxy = useCallback(() => { | ||
| if ((window as any).posthog?.__tiktokProxied) { | ||
| // Restore the original posthog instance | ||
| ;(window as any).posthog = posthog | ||
| setProxyActive(false) | ||
| addLog('TikTok Proxy disabled — window.posthog restored') | ||
| } | ||
| }, [posthog, addLog]) | ||
|
|
||
| const refreshCount = useCallback(() => { | ||
| if (proxyState) { | ||
| setInterceptCount(proxyState.interceptCount) | ||
| } | ||
| }, [proxyState]) | ||
|
|
||
| const testCapture = useCallback(() => { | ||
| try { | ||
| addLog('Calling window.posthog.capture("test-tiktok-event")...') | ||
| ;(window as any).posthog.capture('test-tiktok-event', { source: 'tiktok-proxy-test' }) | ||
| addLog('capture() completed without error') | ||
| } catch (e: any) { | ||
| addLog(`ERROR: ${e.name}: ${e.message}`) | ||
| } | ||
| refreshCount() | ||
| }, [addLog, refreshCount]) | ||
|
|
||
| const testGetFeatureFlag = useCallback(() => { | ||
| try { | ||
| addLog('Calling window.posthog.getFeatureFlag("test-flag")...') | ||
| const result = (window as any).posthog.getFeatureFlag('test-flag') | ||
| addLog(`getFeatureFlag() returned: ${JSON.stringify(result)}`) | ||
| } catch (e: any) { | ||
| addLog(`ERROR: ${e.name}: ${e.message}`) | ||
| } | ||
| refreshCount() | ||
| }, [addLog, refreshCount]) | ||
|
|
||
| const testIsFeatureEnabled = useCallback(() => { | ||
| try { | ||
| addLog('Calling window.posthog.isFeatureEnabled("test-flag")...') | ||
| const result = (window as any).posthog.isFeatureEnabled('test-flag') | ||
| addLog(`isFeatureEnabled() returned: ${JSON.stringify(result)}`) | ||
| } catch (e: any) { | ||
| addLog(`ERROR: ${e.name}: ${e.message}`) | ||
| } | ||
| refreshCount() | ||
| }, [addLog, refreshCount]) | ||
|
|
||
| const testHasOptedOut = useCallback(() => { | ||
| try { | ||
| addLog('Calling window.posthog.has_opted_out_capturing()...') | ||
| const result = (window as any).posthog.has_opted_out_capturing() | ||
| addLog(`has_opted_out_capturing() returned: ${JSON.stringify(result)}`) | ||
| } catch (e: any) { | ||
| addLog(`ERROR: ${e.name}: ${e.message}`) | ||
| } | ||
| refreshCount() | ||
| }, [addLog, refreshCount]) | ||
|
|
||
| const testAllMethods = useCallback(() => { | ||
| addLog('--- Running all method tests ---') | ||
| testCapture() | ||
| testGetFeatureFlag() | ||
| testIsFeatureEnabled() | ||
| testHasOptedOut() | ||
| addLog('--- All tests complete ---') | ||
| }, [addLog, testCapture, testGetFeatureFlag, testIsFeatureEnabled, testHasOptedOut]) | ||
|
|
||
| return ( | ||
| <div style={{ padding: 20, fontFamily: 'system-ui, sans-serif' }}> | ||
| <h1>TikTok In-App Browser Proxy Reproduction</h1> | ||
| <p style={{ color: '#666', maxWidth: 700 }}> | ||
| This page simulates the behavior of TikTok's in-app browser, which wraps{' '} | ||
| <code>window.posthog</code> with a JavaScript Proxy that converts method calls into <code>push()</code>{' '} | ||
| calls, causing infinite recursion. | ||
| </p> | ||
|
|
||
| <div | ||
| style={{ | ||
| padding: 12, | ||
| marginBottom: 16, | ||
| borderRadius: 6, | ||
| background: proxyActive ? '#fee2e2' : '#f0fdf4', | ||
| border: `1px solid ${proxyActive ? '#fca5a5' : '#86efac'}`, | ||
| }} | ||
| > | ||
| <strong>Proxy Status:</strong> {proxyActive ? 'ACTIVE' : 'Inactive'} | ||
| {proxyActive && ` — ${interceptCount} interceptions`} | ||
| </div> | ||
|
|
||
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}> | ||
| <button | ||
| onClick={enableProxy} | ||
| disabled={proxyActive} | ||
| style={{ | ||
| padding: '8px 16px', | ||
| background: proxyActive ? '#ccc' : '#ef4444', | ||
| color: 'white', | ||
| border: 'none', | ||
| borderRadius: 4, | ||
| cursor: proxyActive ? 'not-allowed' : 'pointer', | ||
| }} | ||
| > | ||
| Enable TikTok Proxy | ||
| </button> | ||
| <button | ||
| onClick={disableProxy} | ||
| disabled={!proxyActive} | ||
| style={{ | ||
| padding: '8px 16px', | ||
| background: !proxyActive ? '#ccc' : '#22c55e', | ||
| color: 'white', | ||
| border: 'none', | ||
| borderRadius: 4, | ||
| cursor: !proxyActive ? 'not-allowed' : 'pointer', | ||
| }} | ||
| > | ||
| Disable Proxy | ||
| </button> | ||
| </div> | ||
|
|
||
| <h2>Test Methods</h2> | ||
| <p style={{ color: '#666' }}> | ||
| Enable the proxy above, then click these buttons. Without the fix, these will throw{' '} | ||
| <code>RangeError: Maximum call stack size exceeded</code>. | ||
| </p> | ||
|
|
||
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}> | ||
| <button onClick={testCapture} style={{ padding: '8px 16px' }}> | ||
| capture() | ||
| </button> | ||
| <button onClick={testGetFeatureFlag} style={{ padding: '8px 16px' }}> | ||
| getFeatureFlag() | ||
| </button> | ||
| <button onClick={testIsFeatureEnabled} style={{ padding: '8px 16px' }}> | ||
| isFeatureEnabled() | ||
| </button> | ||
| <button onClick={testHasOptedOut} style={{ padding: '8px 16px' }}> | ||
| has_opted_out_capturing() | ||
| </button> | ||
| <button | ||
| onClick={testAllMethods} | ||
| style={{ | ||
| padding: '8px 16px', | ||
| background: '#3b82f6', | ||
| color: 'white', | ||
| border: 'none', | ||
| borderRadius: 4, | ||
| }} | ||
| > | ||
| Run All | ||
| </button> | ||
| </div> | ||
|
|
||
| <h2>Log</h2> | ||
| <button onClick={() => setLog([])} style={{ marginBottom: 8, padding: '4px 12px' }}> | ||
| Clear | ||
| </button> | ||
| <pre | ||
| style={{ | ||
| background: '#1e1e1e', | ||
| color: '#d4d4d4', | ||
| padding: 16, | ||
| borderRadius: 6, | ||
| maxHeight: 400, | ||
| overflow: 'auto', | ||
| fontSize: 13, | ||
| lineHeight: 1.5, | ||
| }} | ||
| > | ||
| {log.length === 0 ? '(no log entries yet)' : log.join('\n')} | ||
| </pre> | ||
| </div> | ||
| ) | ||
| } |
There was a problem hiding this comment.
the PR description says this playground file should be removed before merging - it's still included in the commit
Prompt To Fix With AI
This is a comment left during a code review.
Path: playground/nextjs/pages/tiktok-proxy.tsx
Line: 1:263
Comment:
the PR description says this playground file should be removed before merging - it's still included in the commit
How can I resolve this? If you propose a fix, please make it concise.
rafaeelaudibert
left a comment
There was a problem hiding this comment.
This is insane. Any idea why they'd do it? I tried fixing it but could never repro, amazing job here!
Also, keep the example, that's helpful!
|
I'd imagine this isn't unique to tiktok. 👍 |
|
in-app browsers 😕 |
|
@rafaeelaudibert ty for review! anything in the pursuit of more data i guess lol - not 100% sure tho, if i have time i'm gonna try to repro actually in-tiktok-browser and add some logging to figure out what the proxy is doing |

Problem
tl;dr
window.posthogin a proxy, so:capture, converts them toposthog.push().push()calls_execute_array(), which dispatches methods viathis[name].apply()thisis the Proxy, so the Proxy intercepts again, creating an infinite loopsee https://posthoghelp.zendesk.com/agent/tickets/50812
Changes
adds
_executeArrayDepthto track re-entrance to_execute_arraydoes not call
_execute_arraywhen depth >0; instead dispatches directly from PostHog.prototype to bypass the Proxywill remove the tiktok-proxy playground file before merging, but including it for context / testing
Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file