Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
.claude
.idea
.php_cs
.php_cs.cache
.php-cs-fixer.cache
.phpunit.result.cache

build
composer.lock
coverage
lang
phpunit.xml
psalm.xml
vendor
.php-cs-fixer.cache

CLAUDE.md
settings.json
test-results
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ Since this package reads your lang files (and could theoretically read any other
**Make sure that you only share the locales that you want to share.**
Add route pattern to do so.

### Security Best Practices

1. **Protect your routes** - Always use authentication middleware on the locale API routes:
```php
Route::middleware('auth:sanctum')->group(function () {
Route::get('/locales', ListLocalesController::class);
Route::get('/locales/{locale}', GetLocaleController::class);
});
```

2. **Restrict locale patterns** - Use route constraints to limit valid locale values:
```php
Route::get('/locales/{locale}', GetLocaleController::class)
->where('locale', '[a-z]{2}');
```

3. **Protect your lang directory** - This package uses `File::getRequire()` which executes PHP code from locale files. Never allow untrusted users to write to your `lang/` directory.

4. **Use vendor safelist** - If you enable `load_vendor_files`, consider using the `vendor_safelist` config option to restrict which vendor packages are exposed.

## Testing

```bash
Expand Down
8 changes: 8 additions & 0 deletions config/locale-via-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
*/
'load_vendor_files' => true,

/**
* Vendor safelist.
* If set to an array, only the listed vendor packages will be loaded.
* If set to null, all vendor packages will be loaded (when load_vendor_files is true).
* DEFAULT: null (load all)
*/
'vendor_safelist' => null,

/**
* Should the output be flattened?
* This will return keys as "api.error.401" instead of "api => error => 401".
Expand Down
66 changes: 25 additions & 41 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,43 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
executionOrder="random"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
beStrictAboutOutputDuringTests="true"
verbose="true"
>
<testsuites>
<testsuite name="empuxa Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./src</directory>
</include>
<report>
<html outputDirectory="build/coverage"/>
<text outputFile="build/coverage.txt"/>
<clover outputFile="build/logs/clover.xml"/>
</report>
</coverage>
<logging>
<junit outputFile="build/report.junit.xml"/>
</logging>
<php>
<env name="DB_CONNECTION" value="testing"/>
<env name="FILESYSTEM_DISK" value="local"/>
</php>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" executionOrder="random" failOnWarning="true" failOnRisky="true" failOnEmptyTestSuite="true" beStrictAboutOutputDuringTests="true" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<testsuites>
<testsuite name="empuxa Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage>
<report>
<html outputDirectory="build/coverage"/>
<text outputFile="build/coverage.txt"/>
<clover outputFile="build/logs/clover.xml"/>
</report>
</coverage>
<logging>
<junit outputFile="build/report.junit.xml"/>
</logging>
<php>
<env name="DB_CONNECTION" value="testing"/>
<env name="FILESYSTEM_DISK" value="local"/>
</php>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>
9 changes: 8 additions & 1 deletion src/Controllers/GetLocaleController.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,16 @@ private function getMergedLocaleData(string $locale): array
if (config('locale-via-api.load_vendor_files', true)) {
// Get vendor directories
$vendorLocales = File::directories(lang_path('vendor'));
$safelist = config('locale-via-api.vendor_safelist');

foreach ($vendorLocales as $vendorLocale) {
$vendorName = basename($vendorLocale);

// Skip if safelist is set and vendor is not in it
if (is_array($safelist) && ! in_array($vendorName, $safelist, true)) {
continue;
}

$data = array_merge_recursive(
$data,
$this->getVendorLocaleData(sprintf('vendor/%s/%s', $vendorName, $locale), $vendorName)
Expand Down Expand Up @@ -179,7 +186,7 @@ private function createJsonResponse(array $data): JsonResponse
return response()->json([
'data' => $data,
'meta' => [
'hash' => md5(json_encode($data)),
'hash' => hash('sha256', json_encode($data)),
],
]);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Controllers/ListLocalesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public function __invoke(): JsonResponse
return response()->json([
'data' => config('locale-via-api.locales'),
'meta' => [
'hash' => md5(json_encode(config('locale-via-api.locales'))),
'hash' => hash('sha256', json_encode(config('locale-via-api.locales'))),
],
]);
}
Expand Down
54 changes: 49 additions & 5 deletions tests/Feature/GetLocaleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
->and($responseData['data'])->toBeArray()
->and($responseData['data'])->toBe(['test' => ['title' => 'Test']]);

$expectedHash = md5(json_encode(['test' => ['title' => 'Test']]));
$expectedHash = hash('sha256', json_encode(['test' => ['title' => 'Test']]));

expect($responseData['meta']['hash'])->toEqual($expectedHash);
});
Expand All @@ -76,7 +76,7 @@
->and($responseData['data']['test'])->toBe(['title' => 'Test'])
->and($responseData['data']['vendor.test-plugin.vendor-test'])->toBe(['title' => 'Vendor Test']);

$expectedHash = md5(json_encode([
$expectedHash = hash('sha256', json_encode([
'test' => ['title' => 'Test'],
'vendor.test-plugin.vendor-test' => ['title' => 'Vendor Test'],
]));
Expand All @@ -103,7 +103,7 @@
->and($responseData['data'])->not->toHaveKey('vendor.test-plugin.vendor-test')
->and($responseData['data']['test'])->toBe(['title' => 'Test']);

$expectedHash = md5(json_encode([
$expectedHash = hash('sha256', json_encode([
'test' => ['title' => 'Test'],
]));

Expand Down Expand Up @@ -131,7 +131,7 @@
'test.api.error.422' => 'Unprocessable Entity.',
]);

$expectedHash = md5(json_encode([
$expectedHash = hash('sha256', json_encode([
'test.api.error.401' => 'Unauthenticated.',
'test.api.error.403' => 'Forbidden.',
'test.api.error.404' => 'Not Found.',
Expand Down Expand Up @@ -163,7 +163,7 @@
->and($responseData['data']['test.api.error.401'])->toBe('Unauthenticated.')
->and($responseData['data']['vendor.test-plugin.vendor-test.title'])->toBe('Vendor Test');

$expectedHash = md5(json_encode([
$expectedHash = hash('sha256', json_encode([
'test.api.error.401' => 'Unauthenticated.',
'test.api.error.403' => 'Forbidden.',
'test.api.error.404' => 'Not Found.',
Expand All @@ -173,3 +173,47 @@

expect($responseData['meta']['hash'])->toEqual($expectedHash);
});

it('returns 404 for path traversal attempts', function () {
$controller = new GetLocaleController;
$request = new Request;

$controller($request, '../../../etc/passwd');
})->throws(Symfony\Component\HttpKernel\Exception\HttpException::class);

it('returns 404 for non-whitelisted locale', function () {
$controller = new GetLocaleController;
$request = new Request;

$controller($request, 'fr');
})->throws(Symfony\Component\HttpKernel\Exception\HttpException::class);

it('returns 404 for locale with special characters', function () {
$controller = new GetLocaleController;
$request = new Request;

$controller($request, 'en/../de');
})->throws(Symfony\Component\HttpKernel\Exception\HttpException::class);

it('respects vendor safelist configuration', function () {
config(['locale-via-api.load_vendor_files' => true]);
config(['locale-via-api.vendor_safelist' => ['allowed-plugin']]);

File::makeDirectory(lang_path('vendor/allowed-plugin/en'), 0755, true);
File::makeDirectory(lang_path('vendor/blocked-plugin/en'), 0755, true);

File::put(lang_path('vendor/allowed-plugin/en/allowed.php'), "<?php return ['title' => 'Allowed'];");
File::put(lang_path('vendor/blocked-plugin/en/blocked.php'), "<?php return ['title' => 'Blocked'];");

$controller = new GetLocaleController;
$request = new Request;
$response = $controller($request, 'en');

$responseData = $response->getData(true);

expect($responseData['data'])->toHaveKey('vendor.allowed-plugin.allowed')
->and($responseData['data'])->not->toHaveKey('vendor.blocked-plugin.blocked');

File::deleteDirectory(lang_path('vendor/allowed-plugin'));
File::deleteDirectory(lang_path('vendor/blocked-plugin'));
});
2 changes: 1 addition & 1 deletion tests/Feature/ListLocalesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@

expect($responseData['data'])->toBeArray()->toBe($mockedLocales);

expect($responseData['meta']['hash'])->toEqual(md5(json_encode($mockedLocales)));
expect($responseData['meta']['hash'])->toEqual(hash('sha256', json_encode($mockedLocales)));
});
Loading