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; + } +}