diff --git a/README.md b/README.md index 19274ff..edf3405 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,50 @@ You can disable caching on a query-by-query basis as needed, like so: ->get(); ``` +#### ⚠️ Laravel 13 `cache.serializable_classes` — Important + +> Laravel 13 introduced [`cache.serializable_classes`](https://laravel.com/docs/13.x/upgrade#cache-serializable_classes-configuration) as a security hardening measure, defaulting to `false` to block deserialization of arbitrary PHP objects from the cache. This package stores `Collection`s of `Address` objects in the cache, which would silently break caching under the new default. + +To keep caching working out of the box without forcing you to maintain an +allow-list as new providers are installed, **this package scans the installed +Geocoder vendor directories at boot and merges every model class it finds into +your application's `cache.serializable_classes` allow-list**. Whatever +providers you have installed under `vendor/geocoder-php/*` are covered +automatically — there's no curated list to go stale. + +**🔐 Security implication:** the package narrowly relaxes Laravel 13's hardening +for the geocoder model classes installed in your `vendor/` directory. Other +PHP objects you store in the cache remain blocked unless you explicitly allow +them. The blast radius is bounded to classes you've already deliberately +installed via composer. + +**Opting out.** Set `auto_register_serializable_classes` to `false` in your +`config/geocoder.php`: + +```php + 'cache' => [ + // ... + + 'auto_register_serializable_classes' => false, + ], +``` + +When opted out, the package will not touch `cache.serializable_classes` at all. +You then have two reasonable paths: + +1. **Manage the allow-list yourself.** Add the geocoder model classes to + `config/cache.php`'s `serializable_classes` directly. Caching keeps working + under your explicit control. Pick this if you want to audit exactly which + PHP objects your application allows to deserialize from cache. + +2. **Disable caching for geocoder queries.** Call + `app('geocoder')->doNotCache()` on each query, or set `cache.duration` to + `0` in `config/geocoder.php`. Pick this if you don't want to maintain the + allow-list and can absorb the per-request API cost. + +Doing neither under Laravel 13 could cause `__PHP_Incomplete_Class` corruption +on cached results. + ### Providers If you are upgrading and have previously published the geocoder config file, you need to add the `cache-duration` variable, otherwise cache will be disabled diff --git a/config/geocoder.php b/config/geocoder.php index a9bb4df..e7ee35a 100644 --- a/config/geocoder.php +++ b/config/geocoder.php @@ -38,6 +38,28 @@ */ 'duration' => 9999999, + + /* + |----------------------------------------------------------------------- + | Auto-Register Serializable Classes (Laravel 13+) + |----------------------------------------------------------------------- + | + | Laravel 13 hardens cache deserialization via `cache.serializable_classes`, + | which defaults to `false` and blocks all object deserialization. With + | this option enabled (the default), the package scans the installed + | Geocoder vendor directories at boot and merges every model class it + | finds into `cache.serializable_classes`, so caching keeps working with + | any provider you have installed. + | + | Set to `false` to opt out entirely — the package will not touch + | `cache.serializable_classes`, and you take responsibility for managing + | the allow-list yourself (or for disabling caching via `doNotCache()`). + | + | Default: true + | + */ + + 'auto_register_serializable_classes' => true, ], /* diff --git a/src/Providers/GeocoderService.php b/src/Providers/GeocoderService.php index f815b9d..a79e821 100644 --- a/src/Providers/GeocoderService.php +++ b/src/Providers/GeocoderService.php @@ -1,48 +1,218 @@ - - * @license MIT License + * @author Mike Bronner + * @license MIT License */ +declare(strict_types=1); + +namespace Geocoder\Laravel\Providers; + use Geocoder\Laravel\Facades\Geocoder; use Geocoder\Laravel\ProviderAndDumperAggregator; +use Geocoder\Model\Address; +use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; +use PhpToken; +use ReflectionClass; class GeocoderService extends ServiceProvider { + // phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingAnyTypeHint protected $defer = false; + protected static array $discoveredSerializableClasses = []; - public function boot() + public function boot(): void { $configPath = __DIR__ . "/../../config/geocoder.php"; - $this->publishes( - [$configPath => $this->configPath("geocoder.php")], - "config" - ); + $this->publishes([$configPath => $this->configPath("geocoder.php")], "config"); $this->mergeConfigFrom($configPath, "geocoder"); + $this->registerSerializableClasses(); + } + + public function provides(): array + { + return ["geocoder", ProviderAndDumperAggregator::class]; } - public function register() + public function register(): void { $this->app->alias("Geocoder", Geocoder::class); $this->app->singleton(ProviderAndDumperAggregator::class, function () { return (new ProviderAndDumperAggregator) ->registerProvidersFromConfig(collect(config("geocoder.providers"))); }); - $this->app->bind('geocoder', ProviderAndDumperAggregator::class); + $this->app->bind("geocoder", ProviderAndDumperAggregator::class); } - public function provides() : array + protected function registerSerializableClasses(): void { - return ["geocoder", ProviderAndDumperAggregator::class]; + if (! config("geocoder.cache.auto_register_serializable_classes", true)) { + return; + } + + if (self::$discoveredSerializableClasses === []) { + self::$discoveredSerializableClasses = $this->discoverSerializableClasses(); + } + + $existing = config("cache.serializable_classes"); + $existing = is_array($existing) + ? $existing + : []; + + config([ + "cache.serializable_classes" => collect($existing) + ->concat(self::$discoveredSerializableClasses) + ->unique() + ->values() + ->toArray(), + ]); + } + + protected function discoverSerializableClasses(): array + { + $vendorRoot = $this->vendorRoot(); + + if ($vendorRoot === null) { + return [Collection::class]; + } + + return collect([ + "{$vendorRoot}/willdurand/geocoder/Model", + "{$vendorRoot}/geocoder-php/*/Model", + ]) + ->flatMap(function (string $pattern): array { + return glob($pattern) + ?: []; + }) + ->flatMap(function (string $directory): array { + return glob("{$directory}/*.php") + ?: []; + }) + ->flatMap(function (string $file): array { + return $this->classNamesFromVendorFile($file); + }) + ->prepend(Collection::class) + ->unique() + ->values() + ->toArray(); + } + + protected function vendorRoot(): ?string + { + $addressFile = (new ReflectionClass(Address::class))->getFileName(); + + if ($addressFile === false) { + return null; + } + + $directory = dirname($addressFile); + + while ( + $directory !== "" + && $directory !== "/" + && basename($directory) !== "vendor" + ) { + $parent = dirname($directory); + + if ($parent === $directory) { + return null; + } + + $directory = $parent; + } + + return basename($directory) === "vendor" + ? $directory + : null; + } + + protected function classNamesFromVendorFile(string $file): array + { + $contents = file_get_contents($file); + + if ($contents === false) { + return []; + } + + return $this->extractClassesFromTokens($this->tokenize($contents)); + } + + protected function tokenize(string $contents): array + { + return array_values(array_filter( + PhpToken::tokenize($contents), + fn (PhpToken $token): bool => ! $token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]), + )); + } + + protected function extractClassesFromTokens(array $tokens): array + { + $namespace = ""; + $classes = []; + + foreach ($tokens as $tokenIndex => $token) { + if ($token->is(T_NAMESPACE)) { + $namespace = $this->readNamespaceAt($tokens, $tokenIndex + 1); + + continue; + } + + if (! $this->isClassDeclaration($tokens, $tokenIndex)) { + continue; + } + + $classes[] = $this->qualify($namespace, $tokens[$tokenIndex + 1]->text); + } + + return $classes; + } + + protected function isClassDeclaration(array $tokens, int $tokenIndex): bool + { + if ( + ! $tokens[$tokenIndex]->is(T_CLASS) + || ( + $tokenIndex > 0 + && $tokens[$tokenIndex - 1]->is(T_NEW) + ) + ) { + return false; + } + + return isset($tokens[$tokenIndex + 1]) + && $tokens[$tokenIndex + 1]->is(T_STRING); + } + + protected function qualify(string $namespace, string $name): string + { + return $namespace !== "" + ? "{$namespace}\\{$name}" + : $name; + } + + protected function readNamespaceAt(array $tokens, int $startingTokenIndex): string + { + $namespaceParts = []; + $tokenCount = count($tokens); + + for ($tokenIndex = $startingTokenIndex; $tokenIndex < $tokenCount; $tokenIndex++) { + if (! $tokens[$tokenIndex]->is([T_STRING, T_NAME_QUALIFIED, T_NS_SEPARATOR])) { + break; + } + + $namespaceParts[] = $tokens[$tokenIndex]->text; + } + + return implode("", $namespaceParts); } - protected function configPath(string $path = "") : string + protected function configPath(string $path = ""): string { if (function_exists("config_path")) { return config_path($path); diff --git a/tests/Feature/Providers/GeocoderServiceTest.php b/tests/Feature/Providers/GeocoderServiceTest.php index 84dc0fb..e4af6a2 100644 --- a/tests/Feature/Providers/GeocoderServiceTest.php +++ b/tests/Feature/Providers/GeocoderServiceTest.php @@ -372,3 +372,121 @@ expect($results->isNotEmpty())->toBeTrue(); expect($store->get($hashedCacheKey)['value'])->toBeInstanceOf(Collection::class); }); + +it('discovers and registers base Geocoder model classes from vendor', function () { + $registered = config('cache.serializable_classes'); + + expect($registered)->toBeArray(); + expect($registered)->toContain(Collection::class); + expect($registered)->toContain(\Geocoder\Model\Address::class); + expect($registered)->toContain(\Geocoder\Model\AdminLevel::class); + expect($registered)->toContain(\Geocoder\Model\AdminLevelCollection::class); + expect($registered)->toContain(\Geocoder\Model\Bounds::class); + expect($registered)->toContain(\Geocoder\Model\Coordinates::class); + expect($registered)->toContain(\Geocoder\Model\Country::class); +}); + +it('discovers and registers installed provider model classes from vendor', function () { + $registered = config('cache.serializable_classes'); + + expect($registered)->toContain(\Geocoder\Provider\Nominatim\Model\NominatimAddress::class); +}); + +it('does not register provider classes whose package is not installed', function () { + $registered = config('cache.serializable_classes'); + + expect(class_exists('Geocoder\\Provider\\BingMaps\\Model\\BingAddress'))->toBeFalse(); + expect($registered)->not->toContain('Geocoder\\Provider\\BingMaps\\Model\\BingAddress'); +}); + +it('produces no duplicate entries in cache.serializable_classes', function () { + $registered = config('cache.serializable_classes'); + + expect($registered)->toBe(array_values(array_unique($registered))); +}); + +it('preserves pre-existing cache.serializable_classes entries when merging', function () { + config(['cache.serializable_classes' => [\stdClass::class]]); + + $provider = new GeocoderService(app()); + $method = new \ReflectionMethod($provider, 'registerSerializableClasses'); + $method->setAccessible(true); + $method->invoke($provider); + + $registered = config('cache.serializable_classes'); + expect($registered)->toContain(\stdClass::class); + expect($registered)->toContain(Collection::class); +}); + +it('coerces a false cache.serializable_classes (Laravel 13 default) to an array', function () { + config(['cache.serializable_classes' => false]); + + $provider = new GeocoderService(app()); + $method = new \ReflectionMethod($provider, 'registerSerializableClasses'); + $method->setAccessible(true); + $method->invoke($provider); + + $registered = config('cache.serializable_classes'); + expect($registered)->toBeArray(); + expect($registered)->toContain(Collection::class); +}); + +it('skips auto-registration when geocoder.cache.auto_register_serializable_classes is false', function () { + config([ + 'geocoder.cache.auto_register_serializable_classes' => false, + 'cache.serializable_classes' => false, + ]); + + $provider = new GeocoderService(app()); + $method = new \ReflectionMethod($provider, 'registerSerializableClasses'); + $method->setAccessible(true); + $method->invoke($provider); + + expect(config('cache.serializable_classes'))->toBeFalse(); +}); + +it('extracts every class from a multi-class file', function () { + $provider = new GeocoderService(app()); + $method = new \ReflectionMethod($provider, 'classNamesFromVendorFile'); + $method->setAccessible(true); + + $result = $method->invoke( + $provider, + __DIR__ . '/../../Support/Fixtures/MultipleClasses.php' + ); + + expect($result)->toBe([ + 'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Multi\\First', + 'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Multi\\Second', + ]); +}); + +it('handles bracketed namespace syntax', function () { + $provider = new GeocoderService(app()); + $method = new \ReflectionMethod($provider, 'classNamesFromVendorFile'); + $method->setAccessible(true); + + $result = $method->invoke( + $provider, + __DIR__ . '/../../Support/Fixtures/BracketedNamespace.php' + ); + + expect($result)->toBe([ + 'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Bracketed\\InsideBrackets', + ]); +}); + +it('skips anonymous classes and string-literal class declarations', function () { + $provider = new GeocoderService(app()); + $method = new \ReflectionMethod($provider, 'classNamesFromVendorFile'); + $method->setAccessible(true); + + $result = $method->invoke( + $provider, + __DIR__ . '/../../Support/Fixtures/AnonymousAndStringLiteral.php' + ); + + expect($result)->toBe([ + 'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Tricky\\Real', + ]); +}); diff --git a/tests/Support/Fixtures/AnonymousAndStringLiteral.php b/tests/Support/Fixtures/AnonymousAndStringLiteral.php new file mode 100644 index 0000000..dbcc32f --- /dev/null +++ b/tests/Support/Fixtures/AnonymousAndStringLiteral.php @@ -0,0 +1,17 @@ +