feat(workflows): emit workflows engagement events from plugin-server#60212
feat(workflows): emit workflows engagement events from plugin-server#60212dmarchuk wants to merge 11 commits into
Conversation
Reads the workflows_teamworkflowsconfig table to decide whether to capture $messaging_email_* events. Depends on PR #50122 landing first so the table exists.
|
| properties: { | ||
| $workflow_id: invocation.functionId, | ||
| $email_to: params.to.email, | ||
| $email_subject: params.subject, | ||
| }, |
There was a problem hiding this comment.
The
$messaging_source property is missing from these events. The PR description explicitly states "Properties on every event: $workflow_id, $messaging_source", and every other event path (all events flowing through trackMetric) does include it. email_sent/email_failed are always emitted synchronously by the service itself, so the source is always 'direct'.
| properties: { | |
| $workflow_id: invocation.functionId, | |
| $email_to: params.to.email, | |
| $email_subject: params.subject, | |
| }, | |
| properties: { | |
| $workflow_id: invocation.functionId, | |
| $messaging_source: 'direct', | |
| $email_to: params.to.email, | |
| $email_subject: params.subject, | |
| }, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: nodejs/src/cdp/services/messaging/email.service.ts
Line: 138-142
Comment:
The `$messaging_source` property is missing from these events. The PR description explicitly states "Properties on every event: `$workflow_id`, `$messaging_source`", and every other event path (all events flowing through `trackMetric`) does include it. `email_sent`/`email_failed` are always emitted synchronously by the service itself, so the source is always `'direct'`.
```suggestion
properties: {
$workflow_id: invocation.functionId,
$messaging_source: 'direct',
$email_to: params.to.email,
$email_subject: params.subject,
},
```
How can I resolve this? If you propose a fix, please make it concise.| export const resolveEmailEngagementDistinctId = ( | ||
| invocation: Pick<CyclotronJobInvocationHogFunction, 'state'> | ||
| ): string | undefined => { | ||
| return invocation.state?.globals?.event?.distinct_id || invocation.state?.globals?.person?.id || undefined |
There was a problem hiding this comment.
Using
|| here will silently skip a truthy-looking but falsy-in-JS distinct_id such as "0" and fall through to person.id, misattributing the event. An explicit empty-string check avoids the ambiguity.
| return invocation.state?.globals?.event?.distinct_id || invocation.state?.globals?.person?.id || undefined | |
| const distinctId = invocation.state?.globals?.event?.distinct_id | |
| return (distinctId !== '' && distinctId != null ? distinctId : invocation.state?.globals?.person?.id) || undefined |
Prompt To Fix With AI
This is a comment left during a code review.
Path: nodejs/src/cdp/services/messaging/email-tracking.service.ts
Line: 62
Comment:
Using `||` here will silently skip a truthy-looking but falsy-in-JS `distinct_id` such as `"0"` and fall through to `person.id`, misattributing the event. An explicit empty-string check avoids the ambiguity.
```suggestion
const distinctId = invocation.state?.globals?.event?.distinct_id
return (distinctId !== '' && distinctId != null ? distinctId : invocation.state?.globals?.person?.id) || undefined
```
How can I resolve this? If you propose a fix, please make it concise.|
|
||
| const eventName = METRIC_NAME_TO_EVENT_NAME[metricName] | ||
| if (eventName && distinctId && (await this.teamWorkflowsConfigService.shouldCaptureEngagementEvents(teamId))) { | ||
| await this.capturedEventsService.queueEvent({ |
There was a problem hiding this comment.
Unauthenticated SES webhook injection: raw-mode bypass + new event capture path
The SES webhook handler verifies SNS signatures only when the request body contains an SNS envelope (checks 'envelope' in parsed). Posting a raw JSON array to the public /public/m/ses_webhook endpoint instead is parsed as mode: 'raw' — the signature check is skipped entirely.
Before this PR the bypass had minimal impact (only inflated metric counters). This PR adds a concrete consequence: the new capturedEventsService.queueEvent call on this line fires for every processed SES metric. An attacker who has a valid tracking code (extractable from any marketing email sent through a PostHog workflow — the ph_id tag encodes functionId in plain base64) can inject arbitrary $messaging_email_link_clicked / $messaging_email_opened / etc. events into a team's PostHog analytics with fully attacker-controlled distinct_id, $link_url, $email_to, and timestamp. The only gate is the team having capture_engagement_events=true, but opt-in teams are fully exposed.
Exploit path: POST /public/m/ses_webhook with body [{"eventType":"Click","mail":{"timestamp":"...","source":"x","messageId":"x","destination":["victim@corp.com"],"tags":{"ph_id":["<stolen_ph_id>"]}},"click":{"link":"https://evil.com","timestamp":"2020-01-01T00:00:00Z"}}] — no authentication required.
Prompt To Fix With AI
In `nodejs/src/cdp/services/messaging/helpers/ses.ts`, the `handleWebhook` method must also reject or verify raw-mode (array) requests that arrive without a verified SNS signature. Two options:
Option A — refuse raw mode from unauthenticated callers: In `parseIncomingBody`, treat any non-envelope body as suspect and return a marker flag. In `handleWebhook`, when `verifySignature` is true and mode is `'raw'`, return `{ status: 403, body: { error: 'Raw SES delivery not accepted; use SNS envelope mode' } }`.
Option B — add a shared secret: Add a configurable `SES_WEBHOOK_SECRET` env var. Require callers to include it as a query parameter or `X-Webhook-Secret` header. Check it in `handleSesWebhook` before delegating to `sesWebhookHandler.handleWebhook`. Apply the same check regardless of envelope vs raw mode.
Option A is simpler and eliminates the raw-mode attack surface entirely if PostHog's SNS subscriptions always use envelope delivery. If raw delivery is intentionally supported, use Option B.Severity: medium | Confidence: 78%
…hog-events-plugin-server
| ) | ||
|
|
||
| const eventName = METRIC_NAME_TO_EVENT_NAME[metricName] | ||
| if (eventName && distinctId && (await this.teamWorkflowsConfigService.shouldCaptureEngagementEvents(teamId))) { |
There was a problem hiding this comment.
Medium: Forged engagement events
/public/m/ses_webhook accepts raw SES records without SNS signature verification, and this new block turns those records into captured events. An attacker who has or can construct a ph_id for a workflow can POST a raw Open/Click/Bounce record and insert $messaging_email_* events for an arbitrary distinctId in that team; only emit captured events after a verified SNS envelope, or add integrity protection to the tracking code and reject unsigned raw delivery on the public endpoint.
PR overviewThe PR has one open security issue: the public SES webhook path can accept unsigned/raw engagement records and convert them into messaging engagement events. If an attacker can obtain or construct a valid tracking identifier, they could forge open/click/bounce activity for a chosen user within a team, which affects event integrity rather than directly exposing data or bypassing authentication. No issues have been addressed yet, so the security posture still depends on adding SNS signature verification or equivalent integrity checks before emitting these events. Open issues (1)
Fixed/addressed: 0 · PR risk: 6/10 |
Append a 16-byte HMAC-SHA256 signature (keyed by ENCRYPTION_SALT_KEYS) to the
ph_id tracking code embedded in workflow email pixels, redirect links, and SES
EmailTags. Format becomes <base64-payload>.<base64-signature>; the '.' separator
disambiguates signed and unsigned codes (base64url alphabet does not include '.',
so legacy codes are never mistaken for signed).
Backwards-compatible by design: codes without a separator parse as legacy
unsigned so in-flight emails at deploy time still resolve. The new
email_tracking_code_format_total{format,source} counter is the rollout signal —
once format=unsigned decays to zero on both source=ses and source=tracking it is
safe to follow up with a parser change that rejects unsigned codes outright.
ENCRYPTION_SALT_KEYS is read comma-separated: codes are signed with the first
key and verified against any, so rotation is a config-only operation. If no
keys are configured the generator emits unsigned codes — same behaviour as
today, so dev environments keep working without setup.
Closes the forgeable-ph_id surface that #60212 would otherwise amplify: with
signed codes an attacker can no longer mint a tracking identifier with arbitrary
distinct_id, teamId, or link_url. Replay of legitimately-issued codes is still
possible until the unsigned path is closed, but the blast radius drops from
'inject arbitrary engagement events for any team' to 'refire events for an
email that was actually sent.'
Append a 16-byte HMAC-SHA256 signature (keyed by ENCRYPTION_SALT_KEYS) to the
ph_id tracking code embedded in workflow email pixels, redirect links, and SES
EmailTags. Format becomes <base64-payload>.<base64-signature>; the '.' separator
disambiguates signed and unsigned codes (base64url alphabet does not include '.',
so legacy codes are never mistaken for signed).
Backwards-compatible by design: codes without a separator parse as legacy
unsigned so in-flight emails at deploy time still resolve. The new
email_tracking_code_format_total{format,source} counter is the rollout signal —
once format=unsigned decays to zero on both source=ses and source=tracking it is
safe to follow up with a parser change that rejects unsigned codes outright.
ENCRYPTION_SALT_KEYS is read comma-separated: codes are signed with the first
key and verified against any, so rotation is a config-only operation. If no
keys are configured the generator emits unsigned codes — same behaviour as
today, so dev environments keep working without setup.
Closes the forgeable-ph_id surface that #60212 would otherwise amplify: with
signed codes an attacker can no longer mint a tracking identifier with arbitrary
distinct_id, teamId, or link_url. Replay of legitimately-issued codes is still
possible until the unsigned path is closed, but the blast radius drops from
'inject arbitrary engagement events for any team' to 'refire events for an
email that was actually sent.'
Append a 16-byte HMAC-SHA256 signature (keyed by ENCRYPTION_SALT_KEYS) to the
ph_id tracking code embedded in workflow email pixels, redirect links, and SES
EmailTags. Format becomes <base64-payload>.<base64-signature>; the '.' separator
disambiguates signed and unsigned codes (base64url alphabet does not include '.',
so legacy codes are never mistaken for signed).
Backwards-compatible by design: codes without a separator parse as legacy
unsigned so in-flight emails at deploy time still resolve. The new
email_tracking_code_format_total{format,source} counter is the rollout signal —
once format=unsigned decays to zero on both source=ses and source=tracking it is
safe to follow up with a parser change that rejects unsigned codes outright.
ENCRYPTION_SALT_KEYS is read comma-separated: codes are signed with the first
key and verified against any, so rotation is a config-only operation. If no
keys are configured the generator emits unsigned codes — same behaviour as
today, so dev environments keep working without setup.
Closes the forgeable-ph_id surface that #60212 would otherwise amplify: with
signed codes an attacker can no longer mint a tracking identifier with arbitrary
distinct_id, teamId, or link_url. Replay of legitimately-issued codes is still
possible until the unsigned path is closed, but the blast radius drops from
'inject arbitrary engagement events for any team' to 'refire events for an
email that was actually sent.'
…hog-events-plugin-server
…SES 256-char tag limit
Append a 16-byte HMAC-SHA256 signature (keyed by ENCRYPTION_SALT_KEYS) to the
ph_id tracking code embedded in workflow email pixels, redirect links, and SES
EmailTags. Format becomes <base64-payload>.<base64-signature>; the '.' separator
disambiguates signed and unsigned codes (base64url alphabet does not include '.',
so legacy codes are never mistaken for signed).
Backwards-compatible by design: codes without a separator parse as legacy
unsigned so in-flight emails at deploy time still resolve. The new
email_tracking_code_format_total{format,source} counter is the rollout signal —
once format=unsigned decays to zero on both source=ses and source=tracking it is
safe to follow up with a parser change that rejects unsigned codes outright.
ENCRYPTION_SALT_KEYS is read comma-separated: codes are signed with the first
key and verified against any, so rotation is a config-only operation. If no
keys are configured the generator emits unsigned codes — same behaviour as
today, so dev environments keep working without setup.
Closes the forgeable-ph_id surface that #60212 would otherwise amplify: with
signed codes an attacker can no longer mint a tracking identifier with arbitrary
distinct_id, teamId, or link_url. Replay of legitimately-issued codes is still
possible until the unsigned path is closed, but the blast radius drops from
'inject arbitrary engagement events for any team' to 'refire events for an
email that was actually sent.'
…hog-events-plugin-server # Conflicts: # nodejs/src/cdp/services/messaging/email.service.ts
…hog-events-plugin-server
…hog-events-plugin-server # Conflicts: # nodejs/src/cdp/services/messaging/email-tracking.service.test.ts # nodejs/src/cdp/services/messaging/email-tracking.service.ts
Problem
Follow-up to #50122 (which ships the Django migration, model, API serializer, frontend UI, and IDOR registration). The
Migration & Service Separation Checkblocks PRs that contain both Django migrations andnodejs/changes, so the plugin-server side is split out here. #50122 must merge first — it createsworkflows_teamworkflowsconfig; until then the config queries would 42P01.Relationship to #60229 (please read before merging)
This branch currently also carries its own copy of the email tracking-code HMAC signing +
X-PostHog-Tracking-Codeheader carrier, which is the subject of the dedicated PR #60229. The two copies are independent (not shared commits), and this branch's copy predates #60229's hardening — the raw-delivery rejection and the SES-tag assertion tightening are not here.Intended resolution: merge #60229 first, then rebase this branch and drop the duplicated signing so this PR only adds the engagement events +
distinct_idthreading on top. Treat the signing code here as belonging to #60229's review, not this one.Changes (this PR's contribution)
TeamWorkflowsConfigService—LazyLoader-backed reader overworkflows_teamworkflowsconfig. Default-deny when no row exists, so teams that haven't opted in are unaffected.EmailService— emits$workflows_email_sent/$workflows_email_failed(ontoresult.capturedPostHogEvents) when the team hascapture_workflows_engagement_events = trueand adistinct_idresolves. Fires for both the SES and maildev send paths.EmailTrackingService— queues$workflows_email_opened,_link_clicked,_delivered,_bounced,_blocked,_failedfrom SES webhooks, backed by an explicit allowlist (METRIC_NAME_TO_EVENT_NAME) so unmapped internal metrics can't leak into customer event streams.resolveEmailEngagementDistinctId— falls backevent.distinct_id→person.id, so batch / scheduled workflows (empty triggeringdistinct_id) still produce attributable events.Shared with #60229, to be deduped: the
distinct_idtracking-code segment, the header carrier, and the HMAC signing itself.Events emitted
email_sent$workflows_email_sentemail_failed$workflows_email_failedemail_delivered$workflows_email_deliveredemail_opened$workflows_email_openedemail_link_clicked$workflows_email_link_clickedemail_bounced$workflows_email_bouncedemail_blocked$workflows_email_blockedProperties:
$workflow_idon every event, plus$messaging_source(directorses) on webhook-sourced events. SES events also carry$email_to; clicks carry$link_url; send-time events carry$email_toand$email_subject.How did you test this code?
pnpm exec tsc --noEmit— clean for this PR's filespnpm jest src/cdp/services/messaging/ --runInBand— passing$workflows_email_sentevent landed in ClickHouse for the team with$workflow_id/$email_to/$email_subject. Opens/clicks/bounces require the real SES webhook path and were not exercised locally.Deploy order
workflows_teamworkflowsconfig) — merged.Publish to changelog?
no — covered by #50122.
🤖 Agent context
nodejs/**half of the original combined change, split to satisfy the migration-separation check. Themessaging→workflowsrename (event names and thecapture_workflows_engagement_eventsconfig field) was applied across both halves.resolveEmailEngagementDistinctIdwas added because the original silently dropped engagement events for any workflow whose triggering event had nodistinct_id(scheduled, segment, batch); falling back toperson.idkeeps batch-targeted emails attributable.METRIC_NAME_TO_EVENT_NAMEallowlist is intentionally explicit (no automatic fallback) so the public event surface is curated, not a mirror ofMinimalAppMetric['metric_name'].🤖 Generated with Claude Code