From 812c650e691944ae6e0a525a49950e31f48bb997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 7 Sep 2026 13:23:20 +0200 Subject: [PATCH] Parse the _fbc cookie the way Meta writes it The SDK's Fbc::fromString() only accepts alphanumeric click ids and exactly four segments. Real click ids are base64url, and Meta's own parameter builder appends an appendix segment, so cookies written by the browser pixel were silently dropped and no fbc was sent. Parse the cookie in the bundle with a pattern matching what Meta writes, and log at debug level when a value cannot be used. Fixes #20 --- composer.json | 1 + src/Context/Fbc/CookieBasedFbcContext.php | 52 +++++++-- src/Resources/config/services/context.xml | 3 + .../Context/Fbc/CookieBasedFbcContextTest.php | 107 ++++++++++++++++++ 4 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 tests/Unit/Context/Fbc/CookieBasedFbcContextTest.php diff --git a/composer.json b/composer.json index d0a00ae..7514ccd 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": ">=8.1", "composer/semver": "^3.0", + "psr/log": "^1.1 || ^2.0 || ^3.0", "setono/bot-detection-bundle": "^1.7", "setono/consent-contracts": "^1.1", "setono/meta-conversions-api-php-sdk": "^1.1", diff --git a/src/Context/Fbc/CookieBasedFbcContext.php b/src/Context/Fbc/CookieBasedFbcContext.php index b4f7814..e41f118 100644 --- a/src/Context/Fbc/CookieBasedFbcContext.php +++ b/src/Context/Fbc/CookieBasedFbcContext.php @@ -4,13 +4,31 @@ namespace Setono\MetaConversionsApiBundle\Context\Fbc; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Setono\MetaConversionsApi\ValueObject\Fbc; use Symfony\Component\HttpFoundation\RequestStack; final class CookieBasedFbcContext implements FbcContextInterface { - public function __construct(private readonly RequestStack $requestStack) - { + /** + * Matches the _fbc cookie as Meta writes it today: fb... with an + * optional trailing appendix segment. + * + * This is deliberately more lenient than Fbc::fromString() in the SDK, which only accepts alphanumeric click ids + * and exactly four segments. Real click ids are base64url and contain - and _, and Meta's own parameter builder + * (facebook/capi-param-builder-php) appends a 2 or 8 character appendix, so the strict pattern rejects cookies + * that the browser pixel writes + */ + private const COOKIE_PATTERN = '/^fb\.([012])\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/'; + + private readonly LoggerInterface $logger; + + public function __construct( + private readonly RequestStack $requestStack, + ?LoggerInterface $logger = null, + ) { + $this->logger = $logger ?? new NullLogger(); } public function getFbc(): ?Fbc @@ -20,14 +38,30 @@ public function getFbc(): ?Fbc return null; } - $fbc = $request->cookies->get('_fbc'); - if (is_string($fbc)) { - try { - return Fbc::fromString($fbc); - } catch (\InvalidArgumentException) { - } + $cookie = $request->cookies->get('_fbc'); + if (!is_string($cookie) || '' === $cookie) { + return null; } - return null; + if (1 !== preg_match(self::COOKIE_PATTERN, $cookie, $matches)) { + $this->logger->debug('The _fbc cookie value "{value}" could not be parsed and is ignored', ['value' => $cookie]); + + return null; + } + + try { + return (new Fbc($matches[3])) + ->withSubdomainIndex((int) $matches[1]) + ->withCreationTime((int) $matches[2]) + ; + } catch (\InvalidArgumentException $e) { + // The creation time is in the future or predates Facebook + $this->logger->debug('The _fbc cookie value "{value}" was rejected: {message}', [ + 'value' => $cookie, + 'message' => $e->getMessage(), + ]); + + return null; + } } } diff --git a/src/Resources/config/services/context.xml b/src/Resources/config/services/context.xml index a68a6da..ed7abc7 100644 --- a/src/Resources/config/services/context.xml +++ b/src/Resources/config/services/context.xml @@ -9,6 +9,9 @@ + + + getFbc()); + } + + #[Test] + public function it_returns_null_without_a_cookie(): void + { + self::assertNull((new CookieBasedFbcContext(self::requestStack(null)))->getFbc()); + } + + #[Test] + #[DataProvider('cookies')] + public function it_parses_the_cookie(string $cookie, string $expectedClickId, int $expectedSubdomainIndex, int $expectedCreationTime): void + { + $fbc = (new CookieBasedFbcContext(self::requestStack($cookie)))->getFbc(); + + self::assertInstanceOf(Fbc::class, $fbc); + self::assertSame($expectedClickId, $fbc->getClickId()); + self::assertSame($expectedSubdomainIndex, $fbc->getSubdomainIndex()); + self::assertSame($expectedCreationTime, $fbc->getCreationTime()); + } + + /** + * @return iterable + */ + public static function cookies(): iterable + { + yield 'alphanumeric click id' => [ + 'fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA', + 'IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA', + 1, + 1657051589577, + ]; + + // Real click ids are base64url, so they contain - and _ + yield 'base64url click id' => [ + 'fb.1.1657051589577.IwZXh0bgNhZW0CMTAAAR-uK_5w', + 'IwZXh0bgNhZW0CMTAAAR-uK_5w', + 1, + 1657051589577, + ]; + + // Meta's own parameter builder appends a 2 or 8 character appendix + yield 'appendix v1' => [ + 'fb.2.1657051589577.IwAR0rmfgHgx.AQ', + 'IwAR0rmfgHgx', + 2, + 1657051589577, + ]; + + yield 'appendix v2' => [ + 'fb.0.1657051589577.IwAR0rmfgHgx.AbCdEfGh', + 'IwAR0rmfgHgx', + 0, + 1657051589577, + ]; + } + + #[Test] + #[DataProvider('invalidCookies')] + public function it_returns_null_for_an_unparseable_cookie(string $cookie): void + { + self::assertNull((new CookieBasedFbcContext(self::requestStack($cookie)))->getFbc()); + } + + /** + * @return iterable + */ + public static function invalidCookies(): iterable + { + yield 'empty' => ['']; + yield 'garbage' => ['not-an-fbc-cookie']; + yield 'wrong prefix' => ['xx.1.1657051589577.abc']; + yield 'invalid subdomain index' => ['fb.3.1657051589577.abc']; + yield 'short creation time' => ['fb.1.165705158.abc']; + yield 'missing click id' => ['fb.1.1657051589577.']; + yield 'creation time in the future' => ['fb.1.9999999999999.abc']; + } + + private static function requestStack(?string $cookie): RequestStack + { + $requestStack = new RequestStack(); + $requestStack->push(new Request([], [], [], null === $cookie ? [] : ['_fbc' => $cookie])); + + return $requestStack; + } +}