Skip to content

feat(messaging): support unregistering push tokens via DELETE - #72425

Closed
dmarchuk wants to merge 1 commit into
masterfrom
claude/lucid-pascal-mabdt7
Closed

feat(messaging): support unregistering push tokens via DELETE#72425
dmarchuk wants to merge 1 commit into
masterfrom
claude/lucid-pascal-mabdt7

Conversation

@dmarchuk

Copy link
Copy Markdown
Contributor

Problem

POST /api/push_subscriptions lets a device register a push token, which is stored as the $device_push_subscription_<app_id> person property. But there was no way to un-register. The only removal today is reactive: the CDP send path prunes a token after a provider reports it dead.

The mobile SDKs (iOS/Android, in progress) need an explicit unregister call for reset() / logout. Without it, a logged-out user's person keeps the token, so on a shared device the next user could receive the previous user's workflow notifications.

Changes

Add DELETE /api/push_subscriptions. It unsets the $device_push_subscription_<app_id> person property (the same operation the send path already performs when pruning a dead token). It reuses the register path's auth and integration lookup, and needs only distinct_id + app_id (not device_token / platform). POST behavior is unchanged.

This gives the SDKs a clean lifecycle on reset(): unregister the token from the outgoing identity, then re-register under the new anonymous id (so it follows the device to whoever uses it next via the normal identify-merge).

Note

This endpoint authenticates with the public project token, same as register. So the notification-takeover risk on register (an attacker binding a victim's distinct_id) has a mirror here: an attacker could silence a victim by unregistering their device. Closing that needs caller-identity verification, which is a separate, still-open effort covering both register and unregister — out of scope for this PR, which matches the endpoint's current posture.

How did you test this code?

I (Claude) could not run the Django suite in this environment (no Postgres), so the test below has not run locally and I'm relying on CI. ruff check and ruff format are clean on both files.

Added one endpoint test, test_unregister_unsets_the_subscription: a DELETE with only distinct_id + app_id returns 200 and calls capture with properties={"$unset": [...]}. It guards the regression that matters here — if DELETE reused the register required-fields list (400 on missing device_token) or emitted $set instead of $unset, logout would silently leave the device subscribed. No existing test exercised DELETE. Invoked /writing-tests first; the integration-not-found and team-isolation paths are shared with POST and already covered, so I didn't duplicate them.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

No user-facing posthog.com docs yet. The customer-facing setup doc should land with the SDK support that consumes this endpoint.

🤖 Agent context

Autonomy: Human-driven (agent-assisted). Directed by @dmarchuk, assigned as DRI.

Authored with Claude Code. Skills invoked: /writing-tests. The work came out of a client-libraries question about reset() behavior on logout — the conclusion was that the SDK should unset the token on the outgoing identity and re-register under the anonymous id, and that a server-side unregister endpoint gives it a clean call rather than hand-rolling a $unset.

Decision: modeled unregister as DELETE on the same path (symmetric with POST register) over a POST with an action field. Based purely on master — a caller-identity-verification effort exists separately and is not merged, so this PR does not depend on it (see the note above).


Generated by Claude Code

@dmarchuk dmarchuk self-assigned this Jul 20, 2026
@dmarchuk
dmarchuk force-pushed the claude/lucid-pascal-mabdt7 branch 2 times, most recently from 4a03a0b to b2d2003 Compare July 20, 2026 21:14
@dmarchuk
dmarchuk marked this pull request as ready for review July 21, 2026 06:29
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team July 21, 2026 06:30
Comment thread products/messaging/backend/api/push_subscriptions.py
@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Security Review

The new destructive operation accepts any caller-supplied distinct_id with only a public project token. This allows a caller who knows the app ID and victim identity to remove the victim's push subscription.

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
products/messaging/backend/api/push_subscriptions.py:54
**DELETE Body Is Ignored**

A normal mobile client sends `distinct_id`, `app_id`, and `api_key` in a JSON DELETE body, but the shared `load_data_from_request` helper reads `request.body` only for POST and uses the `data` query parameter for other methods. The payload therefore becomes `None` and this endpoint returns `400 Invalid JSON body`, preventing unregister from working.

### Issue 2 of 3
products/messaging/backend/api/push_subscriptions.py:54
**Preflight Still Rejects DELETE**

Cross-origin JSON DELETE requests require a preflight, but the shared CORS response still advertises only `GET, POST, OPTIONS`. Browser clients therefore reject the unregister request before this new DELETE branch can run.

### Issue 3 of 3
products/messaging/backend/api/push_subscriptions.py:193-203
**Public Token Can Silence Users**

The project token is public, while `distinct_id` comes directly from the request without proof that the caller controls that identity. A caller who knows a configured `app_id` and another user's distinct ID can trigger this `$unset`, stopping that user from receiving notifications until the device registers again.

Reviews (1): Last reviewed commit: "feat(messaging): support unregistering p..." | Re-trigger Greptile

Comment thread products/messaging/backend/api/push_subscriptions.py
Comment thread products/messaging/backend/api/push_subscriptions.py
Comment thread products/messaging/backend/api/push_subscriptions.py
@dmarchuk
dmarchuk marked this pull request as draft July 21, 2026 06:32
@dmarchuk
dmarchuk removed the request for review from a team July 21, 2026 06:34
Add DELETE to /api/push_subscriptions so a device can be explicitly
unregistered, unsetting the $device_push_subscription_<app_id> person
property (the same operation the send path already performs when a
provider reports a dead token).

This gives the mobile SDKs a clean call to make on reset()/logout:
unregister the token from the outgoing identity, then re-register under
the new anonymous id. Without it a logged-out user's person keeps the
token, so on a shared device the next user could receive the previous
user's notifications.

Unregister reuses the register path's auth and integration lookup and
needs only distinct_id + app_id (not device_token/platform). Both
methods read the JSON request body directly (load_data_from_request only
reads the body for POST), and the OPTIONS preflight now advertises
DELETE so a browser client isn't rejected before the branch runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bbav6vE7Tm24KkdRhZmvNM
@hex-security-app

Copy link
Copy Markdown

Thanks for confirming. The finding remains applicable to this PR as it currently adds the unauthenticated destructive path, but it is reasonable to address the shared caller-identity binding in #72488. Please ensure that work covers both POST registration and DELETE unregistration, and prevents a public project token from authorizing operations on an arbitrary distinct_id.

@dmarchuk
dmarchuk marked this pull request as ready for review July 21, 2026 07:07
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team July 21, 2026 07:08
@@ -195,28 +217,24 @@ def push_subscriptions(request: Request):
event_source="push_subscriptions",
distinct_id=distinct_id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Authorize push subscription deletion against the caller identity

The public project token scopes a request only to a team and is embedded in client apps; it does not authorize an arbitrary request-supplied distinct_id. An attacker who knows a project's app ID and a victim's distinct ID can call this DELETE endpoint and reach capture_internal with $unset, removing the victim's subscription and silencing their workflow notifications.

Prompt To Fix With AI
Require a caller-bound, verifiable assertion that authorizes the exact `(distinct_id, app_id)` before emitting the `$unset`; a public project token must not authorize operations on an arbitrary person. Apply the same identity binding to both POST registration and DELETE unregistration, and add tests that a caller cannot remove another user's subscription.

Severity: medium | Confidence: 98% | React with 👍 if useful or 👎 if not

@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
products/messaging/backend/api/push_subscriptions.py:53-57
**Preserve POST form parsing**

This fixes JSON DELETE bodies by always passing `request.body` to `decompress`, but it also changes the existing POST contract. `load_data_from_request` used `request.POST["data"]` for form-encoded and multipart POST requests. The new helper instead tries to decode the raw form body as JSON, so previously supported registration requests return `400 Invalid JSON body`. Keep the existing parser for POST and use direct body parsing only for DELETE.

Reviews (2): Last reviewed commit: "feat(messaging): support unregistering p..." | Re-trigger Greptile

Comment on lines +53 to +57
def _load_json_body(request: Request) -> Any:
compression = (
request.GET.get("compression") or request.POST.get("compression") or request.headers.get("content-encoding", "")
).lower()
return decompress(request.body, compression)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Preserve POST form parsing

This fixes JSON DELETE bodies by always passing request.body to decompress, but it also changes the existing POST contract. load_data_from_request used request.POST["data"] for form-encoded and multipart POST requests. The new helper instead tries to decode the raw form body as JSON, so previously supported registration requests return 400 Invalid JSON body. Keep the existing parser for POST and use direct body parsing only for DELETE.

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/messaging/backend/api/push_subscriptions.py
Line: 53-57

Comment:
**Preserve POST form parsing**

This fixes JSON DELETE bodies by always passing `request.body` to `decompress`, but it also changes the existing POST contract. `load_data_from_request` used `request.POST["data"]` for form-encoded and multipart POST requests. The new helper instead tries to decode the raw form body as JSON, so previously supported registration requests return `400 Invalid JSON body`. Keep the existing parser for POST and use direct body parsing only for DELETE.

How can I resolve this? If you propose a fix, please make it concise.

@dmarchuk

dmarchuk commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Superseded with #72490

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants