Skip to content

Commit 49a5aac

Browse files
committed
refactor(security): redact PostHog exception props via before_send denylist
Adopt PostHog's recommended before_send redaction pattern (https://posthog.com/tutorials/web-redact-properties): null URL/user-agent properties by key name on $exception events. Scoped to exceptions to preserve web analytics URL/referrer context, and the error message text is still scrubbed for embedded secrets rather than nulled so errors stay debuggable.
1 parent eecc3af commit 49a5aac

2 files changed

Lines changed: 70 additions & 13 deletions

File tree

src/features/posthog-sanitize.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,36 @@ import type { CaptureResult } from 'posthog-js'
22

33
const REDACTED = '[redacted]'
44

5-
const REDACTION_RULES: { pattern: RegExp; replacement: string }[] = [
5+
// Property keys (substring match) whose values can carry sensitive data: URLs
6+
// may embed tokens, user agents fingerprint users. Redacted following PostHog's
7+
// recommended before_send pattern:
8+
// https://posthog.com/tutorials/web-redact-properties
9+
const REDACTED_PROPERTY_KEYS = [
10+
'url',
11+
'href',
12+
'pathname',
13+
'referrer',
14+
'host',
15+
'user_agent',
16+
]
17+
18+
function filterProperties(
19+
properties: Record<string, unknown>
20+
): Record<string, unknown> {
21+
return Object.entries(properties).reduce<Record<string, unknown>>(
22+
(acc, [key, value]) => {
23+
acc[key] = REDACTED_PROPERTY_KEYS.some((prop) => key.includes(prop))
24+
? null
25+
: value
26+
return acc
27+
},
28+
{}
29+
)
30+
}
31+
32+
// Secret values that may be embedded in the exception message/stack text, which
33+
// (unlike URL properties) we keep rather than null so errors stay debuggable.
34+
const SECRET_VALUE_RULES: { pattern: RegExp; replacement: string }[] = [
635
// JWTs (header.payload.signature)
736
{
837
pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
@@ -21,34 +50,37 @@ const REDACTION_RULES: { pattern: RegExp; replacement: string }[] = [
2150
},
2251
]
2352

24-
function redact(value: string): string {
25-
return REDACTION_RULES.reduce(
53+
function redactSecrets(value: string): string {
54+
return SECRET_VALUE_RULES.reduce(
2655
(acc, { pattern, replacement }) => acc.replace(pattern, replacement),
2756
value
2857
)
2958
}
3059

3160
/**
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.
61+
* `before_send` hook that scrubs $exception events before they leave the
62+
* browser: nulls URL/user-agent properties (token leak + fingerprinting) and
63+
* strips embedded secrets from the error message text. Scoped to exceptions so
64+
* web analytics keeps its URL/referrer context.
3565
*/
3666
export function sanitizePostHogEvent(
3767
event: CaptureResult | null
3868
): CaptureResult | null {
3969
if (!event || event.event !== '$exception') return event
4070

41-
const props = event.properties
42-
if (!props) return event
71+
event.properties = filterProperties(event.properties ?? {})
72+
if (event.$set) event.$set = filterProperties(event.$set)
73+
if (event.$set_once) event.$set_once = filterProperties(event.$set_once)
4374

44-
if (typeof props.$exception_message === 'string') {
45-
props.$exception_message = redact(props.$exception_message)
75+
const message = event.properties.$exception_message
76+
if (typeof message === 'string') {
77+
event.properties.$exception_message = redactSecrets(message)
4678
}
4779

48-
if (Array.isArray(props.$exception_list)) {
49-
for (const item of props.$exception_list) {
80+
if (Array.isArray(event.properties.$exception_list)) {
81+
for (const item of event.properties.$exception_list) {
5082
if (item && typeof item.value === 'string') {
51-
item.value = redact(item.value)
83+
item.value = redactSecrets(item.value)
5284
}
5385
}
5486
}

tests/unit/posthog-sanitize.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,31 @@ describe('sanitizePostHogEvent', () => {
3030
)
3131
})
3232

33+
it('nulls URL and user-agent properties on exception events', () => {
34+
const event = {
35+
uuid: 'x',
36+
event: '$exception',
37+
properties: {
38+
$current_url: 'https://app/team/abc?token=secret',
39+
$pathname: '/team/abc',
40+
$referrer: 'https://app/login?code=xyz',
41+
$host: 'app.e2b.dev',
42+
$raw_user_agent: 'Mozilla/5.0',
43+
$exception_message: 'boom',
44+
custom_count: 3,
45+
},
46+
} as CaptureResult
47+
const result = sanitizePostHogEvent(event)
48+
expect(result?.properties.$current_url).toBeNull()
49+
expect(result?.properties.$pathname).toBeNull()
50+
expect(result?.properties.$referrer).toBeNull()
51+
expect(result?.properties.$host).toBeNull()
52+
expect(result?.properties.$raw_user_agent).toBeNull()
53+
// Non-sensitive properties are preserved.
54+
expect(result?.properties.custom_count).toBe(3)
55+
expect(result?.properties.$exception_message).toBe('boom')
56+
})
57+
3358
it('redacts JWTs in the exception message', () => {
3459
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.s5G_abcDEF-123'
3560
const event = sanitizePostHogEvent(

0 commit comments

Comments
 (0)