Skip to content

Keep access tokens and raw PII out of the Messenger transport - #49

Open
loevgaard wants to merge 1 commit into
masterfrom
fix/17-pii-in-transport
Open

loevgaard wants to merge 1 commit into
masterfrom
fix/17-pii-in-transport

Conversation

@loevgaard

@loevgaard loevgaard commented Sep 7, 2026

Copy link
Copy Markdown
Member

Fixes #17

Stacked on #48.

Problem

SendEvent carried the whole Event object. At dispatch time that object holds the pixels including their access tokens, and the raw email addresses, phone numbers, names and dates of birth the application attached, because normalisation and hashing only happen inside Parameters::getPayload(), which the SDK calls later in the handler.

Routing the command to a transport, which is the recommended setup, therefore wrote all of that into the transport's storage, into the failure transport when it failed, and into anything that dumps messages such as messenger:failed:show. Failure transports are often kept indefinitely, so it was a retention problem as well as a secrets problem.

Change

SendEvent now carries the finished payload:

new SendEvent($eventName, $eventId, $payload, $pixelIds, $testEventCode);

built with SendEvent::fromEvent($event). The payload is produced by the SDK at dispatch time, so it is already normalised and hashed, and only pixel ids travel.

Access tokens are resolved when the event is sent, through a new AccessTokenResolverInterface. ConfigurationBasedAccessTokenResolver reads them from the pixels configuration, which is request independent and therefore safe to run in a worker. Applications with their own PixelProviderInterface alias the resolver too, as documented in README.md and UPGRADE.md.

PreparedEvent (internal) hands the precomputed payload back to ClientInterface::sendEvent(), which takes an Event. It extends the SDK's Event, which is explicitly documented as non-final for this kind of extension, and overrides getPayload().

A welcome side effect: everything in the message is now a scalar or an array, so it also survives the Symfony serializer rather than requiring the PHP one.

Tests

The important one serialises a message built from an event carrying an access token, an email address, a phone number and a first name, then asserts none of those four strings appear in the serialised output, while the payload holds the SHA-256 of the email. Plus three tests for the resolver, including the fact that numeric pixel ids become integer array keys in PHP, and three for the rewritten handler.

Review findings, folded in

  • Messages from 0.1.x still in a transport. Their body has the old $event shape. Messenger's PhpSerializer decodes it into the current class with every property uninitialised (the class exists, and deprecations are exempt from decoding failures), the handler threw a plain Error, and Messenger retried it three times before parking it — body, token and raw PII included. The handler now detects the old shape and throws UnrecoverableMessageHandlingException, so it goes to the failure transport once, with a reason. UPGRADE.md tells you to drain the transport on the old release before deploying.
  • A custom PixelProviderInterface without a resolver lost all server-side tracking silently. The old handler honoured tokens on the Pixel objects; this PR strips them by design. Every event then logged one warning per pixel and returned — and a stock production Monolog setup buffers warnings away. When no pixel has a token, the handler now logs at error, naming the resolver. The three places in the docs that recommend aliasing the provider now say to alias the resolver too.
  • ConfigurationBasedAccessTokenResolver is added to the integration test's registry of conditional server-side services, so its presence and absence are asserted like the other two.
  • From the second pass, after the rate limit: a pixel listed twice by a provider now travels once, since the client posts once per pixel; and configuring the same pixel id twice is rejected at compile time, because the token is now looked up by id and a second entry would silently win. Also confirmed, with no change needed: a payload that fails to build at raise time is caught by the dispatch subscriber and logged rather than becoming a poison message in the worker; and getPayload()'s browser context is only ever requested from userData and customData, never from the event.

@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.06%. Comparing base (ee105bc) to head (861c973).

Additional details and impacted files
@@             Coverage Diff              @@
##             master      #49      +/-   ##
============================================
+ Coverage     97.95%   98.06%   +0.11%     
- Complexity      151      160       +9     
============================================
  Files            32       34       +2     
  Lines           488      518      +30     
============================================
+ Hits            478      508      +30     
  Misses           10       10              
Files with missing lines Coverage Δ
...Resolver/ConfigurationBasedAccessTokenResolver.php 100.00% <100.00%> (ø)
src/DependencyInjection/Configuration.php 98.80% <100.00%> (+0.05%) ⬆️
...EventSubscriber/DispatchOnCommandBusSubscriber.php 100.00% <100.00%> (ø)
src/Message/Command/SendEvent.php 100.00% <100.00%> (ø)
src/Message/Handler/SendEventHandler.php 100.00% <100.00%> (ø)
src/Message/PreparedEvent.php 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@loevgaard
loevgaard force-pushed the fix/16-sync-failures branch from 7107a61 to b06fbd4 Compare September 7, 2026 12:03
@loevgaard
loevgaard force-pushed the fix/17-pii-in-transport branch from 9a99ee8 to dcbe1cb Compare September 7, 2026 12:03
@loevgaard
loevgaard force-pushed the fix/16-sync-failures branch from b06fbd4 to db88227 Compare September 7, 2026 12:18
@loevgaard
loevgaard force-pushed the fix/17-pii-in-transport branch from dcbe1cb to af21153 Compare September 7, 2026 12:18
@loevgaard
loevgaard force-pushed the fix/16-sync-failures branch from db88227 to bb2ba25 Compare September 7, 2026 12:46
@loevgaard
loevgaard force-pushed the fix/17-pii-in-transport branch from af21153 to bac5d1e Compare September 7, 2026 12:46
@loevgaard
loevgaard force-pushed the fix/16-sync-failures branch from bb2ba25 to 865684c Compare September 7, 2026 12:51
@loevgaard
loevgaard force-pushed the fix/17-pii-in-transport branch from bac5d1e to c9f1d9d Compare September 7, 2026 12:51
Base automatically changed from fix/16-sync-failures to master September 7, 2026 12:56
@loevgaard
loevgaard force-pushed the fix/17-pii-in-transport branch 3 times, most recently from 77d1a3e to d13d703 Compare September 14, 2026 07:52
@loevgaard
loevgaard changed the base branch from master to fix/27-public-api September 14, 2026 07:53
@loevgaard
loevgaard force-pushed the fix/17-pii-in-transport branch from d13d703 to 6857dd6 Compare September 14, 2026 08:07
@loevgaard
loevgaard changed the base branch from fix/27-public-api to master September 14, 2026 08:26
@loevgaard
loevgaard force-pushed the fix/17-pii-in-transport branch 4 times, most recently from c94281e to e9fee59 Compare September 14, 2026 11:33
SendEvent carried the Event object, so routing it to a transport wrote
the access token and every raw email, phone number and name into that
transport's storage, and into the failure transport on failure. Hashing
only happened later, inside Client::sendEvent().

The command now carries the finished payload and pixel ids only. Access
tokens are resolved at send time through AccessTokenResolverInterface.

From review: a message serialised by 0.1.x is rejected as unrecoverable
instead of being retried three times with its old body intact; an event
none of whose pixels has a token is logged at error level rather than
returning quietly, since a custom PixelProviderInterface without a
matching resolver otherwise loses server side tracking silently; and
the docs say so wherever they recommend aliasing the provider.

Fixes #17
@loevgaard

Copy link
Copy Markdown
Member Author

The two latent PreparedEvent issues from the review — the phantom eventId/eventTime/actionSource, and getPayload() ignoring $context — are deliberately left as they are. PreparedEvent only exists because the SDK client has no way to accept a prepared payload; that is now Setono/meta-conversions-api-php-sdk#15. Once the SDK has Payload and Client::sendPayload(), this PR drops PreparedEvent and both issues disappear with it.

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.

Access tokens and unhashed PII are serialized into the Messenger transport

1 participant