Skip to content

Commit eecc3af

Browse files
committed
fix(security): redact secrets from PostHog exception events
Add a before_send hook that strips JWTs, API-key-like tokens, and sensitive URL query-param values from $exception event text before it leaves the browser. Covers both manual captureException and automatic capture_exceptions so raw error messages/stacks can't leak credentials to PostHog.
1 parent 5a89430 commit eecc3af

3 files changed

Lines changed: 129 additions & 0 deletions

File tree

src/features/posthog-provider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import posthog, { type Survey } from 'posthog-js'
22
import { PostHogProvider as PHProvider } from 'posthog-js/react'
33
import { createContext, useContext, useEffect, useState } from 'react'
4+
import { sanitizePostHogEvent } from './posthog-sanitize'
45

56
interface AppPostHogContextValue {
67
enabled: boolean
@@ -53,6 +54,8 @@ export function PostHogProvider({
5354
capture_unhandled_rejections: true,
5455
capture_console_errors: false,
5556
},
57+
// Redact secrets from exception text before it leaves the browser.
58+
before_send: sanitizePostHogEvent,
5659
advanced_enable_surveys: true,
5760
disable_session_recording: process.env.NODE_ENV !== 'production',
5861
advanced_disable_toolbar_metrics: true,

src/features/posthog-sanitize.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { CaptureResult } from 'posthog-js'
2+
3+
const REDACTED = '[redacted]'
4+
5+
const REDACTION_RULES: { pattern: RegExp; replacement: string }[] = [
6+
// JWTs (header.payload.signature)
7+
{
8+
pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
9+
replacement: REDACTED,
10+
},
11+
// API-key-like tokens (e.g. sk_..., pk_..., rk_..., e2b_...)
12+
{
13+
pattern: /\b(?:sk|pk|rk|e2b)_[A-Za-z0-9-]{8,}\b/gi,
14+
replacement: REDACTED,
15+
},
16+
// Sensitive URL query params: keep the key, drop the value
17+
{
18+
pattern:
19+
/([?&][^=&\s]*(?:token|key|secret|password|passwd|auth|session|code)[^=&\s]*=)[^&\s#"']+/gi,
20+
replacement: `$1${REDACTED}`,
21+
},
22+
]
23+
24+
function redact(value: string): string {
25+
return REDACTION_RULES.reduce(
26+
(acc, { pattern, replacement }) => acc.replace(pattern, replacement),
27+
value
28+
)
29+
}
30+
31+
/**
32+
* `before_send` hook that strips credentials from $exception event text before
33+
* it leaves the browser, so raw error messages/stacks can't leak secrets to
34+
* PostHog. Covers both manual captureException and automatic capture_exceptions.
35+
*/
36+
export function sanitizePostHogEvent(
37+
event: CaptureResult | null
38+
): CaptureResult | null {
39+
if (!event || event.event !== '$exception') return event
40+
41+
const props = event.properties
42+
if (!props) return event
43+
44+
if (typeof props.$exception_message === 'string') {
45+
props.$exception_message = redact(props.$exception_message)
46+
}
47+
48+
if (Array.isArray(props.$exception_list)) {
49+
for (const item of props.$exception_list) {
50+
if (item && typeof item.value === 'string') {
51+
item.value = redact(item.value)
52+
}
53+
}
54+
}
55+
56+
return event
57+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { CaptureResult } from 'posthog-js'
2+
import { describe, expect, it } from 'vitest'
3+
import { sanitizePostHogEvent } from '@/features/posthog-sanitize'
4+
5+
function exceptionEvent(message: string, listValue?: string): CaptureResult {
6+
return {
7+
uuid: 'test',
8+
event: '$exception',
9+
properties: {
10+
$exception_message: message,
11+
...(listValue !== undefined
12+
? { $exception_list: [{ type: 'Error', value: listValue }] }
13+
: {}),
14+
},
15+
} as CaptureResult
16+
}
17+
18+
describe('sanitizePostHogEvent', () => {
19+
it('returns null and non-exception events untouched', () => {
20+
expect(sanitizePostHogEvent(null)).toBeNull()
21+
22+
const pageview = {
23+
uuid: 'x',
24+
event: '$pageview',
25+
properties: { $current_url: 'https://app/?token=secret123' },
26+
} as CaptureResult
27+
expect(sanitizePostHogEvent(pageview)).toBe(pageview)
28+
expect(pageview.properties.$current_url).toBe(
29+
'https://app/?token=secret123'
30+
)
31+
})
32+
33+
it('redacts JWTs in the exception message', () => {
34+
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.s5G_abcDEF-123'
35+
const event = sanitizePostHogEvent(
36+
exceptionEvent(`Auth failed with ${jwt}`)
37+
)
38+
expect(event?.properties.$exception_message).toBe(
39+
'Auth failed with [redacted]'
40+
)
41+
})
42+
43+
it('redacts API-key-like tokens', () => {
44+
const event = sanitizePostHogEvent(
45+
exceptionEvent('bad key e2b_0123456789abcdef and sk_ABCDEFGHIJ')
46+
)
47+
expect(event?.properties.$exception_message).toBe(
48+
'bad key [redacted] and [redacted]'
49+
)
50+
})
51+
52+
it('keeps the key but drops the value of sensitive query params', () => {
53+
const event = sanitizePostHogEvent(
54+
exceptionEvent('GET /cb?access_token=abc123&page=2 failed')
55+
)
56+
expect(event?.properties.$exception_message).toBe(
57+
'GET /cb?access_token=[redacted]&page=2 failed'
58+
)
59+
})
60+
61+
it('redacts values inside $exception_list', () => {
62+
const event = sanitizePostHogEvent(
63+
exceptionEvent('outer', 'inner secret sk_ABCDEFGHIJ token')
64+
)
65+
expect(event?.properties.$exception_list[0].value).toBe(
66+
'inner secret [redacted] token'
67+
)
68+
})
69+
})

0 commit comments

Comments
 (0)