diff --git a/.env b/.env index b2a203f..6292e2c 100644 --- a/.env +++ b/.env @@ -36,3 +36,6 @@ FETCH_ALLOW_REDIRECTS=0 # Force re-fetch token (empty = no protection) FORCE_TOKEN= + +# Signed URL secret (empty = URL signing disabled) +SIGNATURE_SECRET= diff --git a/README.md b/README.md index dede6f2..9306868 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,26 @@ ## Overview This project is a lightweight CDN built with PHP. -It supports fetching, optimizing, caching and serving images dynamically while ensuring high flexibility and efficiency. +It supports fetching, optimizing, caching, and serving images and static assets dynamically while ensuring high flexibility and efficiency. ## Features -- **Domain Restriction:** Allows defining authorized domains via an environment variable (_ALLOWED_DOMAINS_). -- **On-the-Fly Image Processing:** Fetches images from a URL, compresses them lossless, and caches them. -- **WebP Support:** Converts images to WebP format if supported by the requesting client (GIFs are always served as GIF to preserve animation). -- **Animated GIF Support:** Preserves all frames of animated GIFs through resize operations. -- **Static Asset Support:** Fetches, optimizes, and serves JS/CSS/font files with proper cache headers: - - **CSS & JS minification:** Automatically minifies `.css` and `.js` files to reduce file size. +- **Domain Restriction:** Allows defining authorized domains via an environment variable (`ALLOWED_DOMAINS`). +- **On-the-Fly Image Processing:** Fetches images from a URL, compresses them, and caches them. +- **WebP & AVIF Support:** Converts images to WebP or AVIF format based on the client's `Accept` header (AVIF takes priority). +- **Animated GIF Support:** Preserves all frames of animated GIFs through resize operations; converts to animated WebP on demand. +- **EXIF Stripping:** Automatically removes EXIF metadata (GPS, device model…) from processed images to protect user privacy. +- **Dominant Color Header:** Returns an `X-Dominant-Color: #rrggbb` header on image responses for use as a placeholder while the image loads. +- **Static Asset Support:** Fetches, optimizes, and serves static files with proper cache headers: + - **CSS & JS minification:** Automatically minifies `.css` and `.js` files. + - **JSON minification:** Minifies `.json` and `.webmanifest` files. - **Font passthrough:** Serves `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf` with long-term caching. - **SVG & ICO passthrough:** Serves `.svg` and `.ico` with long-term caching. + - **Other passthroughs:** Serves `.xml`, `.txt`, `.map`, `.wasm` as-is with long-term caching. +- **Signed URLs:** Optional HMAC-SHA256 URL signing with expiration (`SIGNATURE_SECRET`). When enabled, requests must carry `?expires=&sig=`. +- **SSRF Protection:** Blocks requests targeting private/reserved IP ranges (loopback, link-local, RFC 1918, etc.) in addition to domain allowlisting. +- **Fetch Hardening:** Configurable timeout, maximum file size, and redirect policy to prevent slow-loris, image-bomb, and SSRF-via-redirect attacks. +- **Force Re-fetch Protection:** Optional secret token required to bypass the cache (`FORCE_TOKEN`). - **Configurable Storage:** Supports both local filesystem and S3-compatible storage. - **Dynamic Image Resizing:** Resize images via query parameters: - `w` (width) @@ -23,11 +31,8 @@ It supports fetching, optimizing, caching and serving images dynamically while e - `wp` (watermark position, default: center) - `ws` (watermark size percentage, default: 75%) - `wo` (watermark opacity percentage, default: 50%) -- **Smart Storage Structure:** Assets are stored based on query parameters. +- **Smart Storage Structure:** Assets are stored based on query parameters for deterministic cache keys. - **Serverless Compatible:** Optimized to run in a serverless environment. -- **SSRF Protection:** Only domains listed in `ALLOWED_DOMAINS` can be fetched (applies to both source images and watermarks). -- **Fetch Hardening:** Configurable timeout, maximum file size, and redirect policy to prevent slow-loris, image-bomb, and SSRF-via-redirect attacks. -- **Force Re-fetch Protection:** Optional secret token required to bypass the cache (`FORCE_TOKEN`). ## Serverless @@ -110,11 +115,14 @@ IMAGE_COMPRESSION=75 # HTTP fetch (timeout in seconds, max size in bytes) FETCH_TIMEOUT=10 FETCH_MAX_SIZE=52428800 -# Set to 1 only if your image origins serve via redirects (SSRF risk — see security notes) +# Set to 1 only if your image origins serve via redirects (SSRF risk - see security notes) FETCH_ALLOW_REDIRECTS=0 # Force re-fetch token (empty = no protection, set to a secret to require ?token=) FORCE_TOKEN= + +# URL signing secret (empty = disabled; when set, all requests must carry ?expires=&sig=) +SIGNATURE_SECRET= ``` ## Running with Docker @@ -143,10 +151,12 @@ https://cdn-php.loc/https://www.mysite.com/image.png?w=200&h=200 The CDN will: - Fetch the image from www.mysite.com +- Strip EXIF metadata - Optimize and compress it -- Convert it to WebP if supported +- Convert it to AVIF or WebP if the client supports it - Store it based on parameters - Serve it with proper caching headers (`Cache-Control`, `ETag`, `Vary: Accept`) +- Add `X-Dominant-Color: #rrggbb` for use as a CSS placeholder ### Static Assets @@ -159,20 +169,96 @@ https://cdn-php.loc/https://www.mysite.com/style.css # JavaScript (automatically minified) https://cdn-php.loc/https://www.mysite.com/app.js +# JSON / Web App Manifest (automatically minified) +https://cdn-php.loc/https://www.mysite.com/manifest.json +https://cdn-php.loc/https://www.mysite.com/app.webmanifest + # Fonts (served as-is with long-term caching) https://cdn-php.loc/https://www.mysite.com/font.woff2 # SVG / ICO (served as-is with long-term caching) https://cdn-php.loc/https://www.mysite.com/logo.svg + +# Other passthroughs +https://cdn-php.loc/https://www.mysite.com/robots.txt +https://cdn-php.loc/https://www.mysite.com/app.js.map +https://cdn-php.loc/https://www.mysite.com/module.wasm ``` -Supported extensions: `css`, `js`, `woff`, `woff2`, `ttf`, `eot`, `otf`, `svg`, `ico`, `xml` +Supported extensions: `css`, `js`, `woff`, `woff2`, `ttf`, `eot`, `otf`, `svg`, `ico`, `xml`, `json`, `webmanifest`, `txt`, `map`, `wasm` + +### Signed URLs + +When `SIGNATURE_SECRET` is set, every CDN request must carry a valid HMAC-SHA256 signature. This prevents anyone from constructing arbitrary CDN URLs directly – only your backend can generate valid ones. + +**How it works:** + +1. Your backend generates a signed URL and injects it into the HTML. +2. The browser fetches the CDN URL (with the signature). +3. The CDN verifies the signature before serving the asset. + +**What gets signed:** + +The signature covers the **source URL** (the image/asset origin, without CDN params) and the expiry timestamp: + +``` +HMAC-SHA256( ":", SIGNATURE_SECRET ) +``` + +**PHP helper (in your application backend):** + +```php +function cdnUrl( + string $cdnBase, + string $sourceUrl, + int $ttl = 3600, + array $params = [], +): string { + $expires = time() + $ttl; + $sig = hash_hmac('sha256', $sourceUrl . ':' . $expires, $_ENV['SIGNATURE_SECRET']); + + return $cdnBase . '/' . $sourceUrl . '?' . http_build_query( + array_merge($params, ['expires' => $expires, 'sig' => $sig]) + ); +} +``` + +**Usage in a Twig template (for example):** + +```php +// In your controller +$imageUrl = cdnUrl( + cdnBase: 'https://cdn.mysite.com', + sourceUrl: 'https://www.mysite.com/uploads/photo.jpg', + ttl: 3600, // link valid for 1 hour + params: ['w' => 800, 'h' => 600], +); +``` + +```html + +Photo +``` + +This produces a URL like: + +``` +https://cdn.mysite.com/https://www.mysite.com/uploads/photo.jpg + ?w=800&h=600&expires=1714000000&sig=a3f2c1... +``` + +**Error responses:** + +| Situation | HTTP status | +|------------------------------------|-----------------| +| `sig` missing or incorrect | `403 Forbidden` | +| `expires` timestamp is in the past | `410 Gone` | -CSS and JS files are automatically minified (comments and unnecessary whitespace removed) before being cached, reducing their size for faster delivery. +> **Important:** `SIGNATURE_SECRET` must be kept server-side only. Never expose it in front-end code or public repositories. ### Compression (GZIP) -**With the serverless setup (Bref / API Gateway):** GZIP compression is handled automatically by API Gateway via the `minimumCompressionSize` setting in `serverless.yml`. Responses larger than 1 KB are compressed transparently based on the client's `Accept-Encoding` header — no application-level changes needed. +**With the serverless setup (Bref / API Gateway):** API Gateway handles GZIP compression automatically via the `minimumCompressionSize` setting in `serverless.yml`. Responses larger than 1 KB are compressed transparently based on the client's `Accept-Encoding` header - no application-level changes needed. **Without serverless (Docker, Nginx, Apache…):** The CDN itself does not add `Content-Encoding: gzip` headers. You must enable compression at the web server or reverse-proxy level: diff --git a/phpmd-ruleset.xml b/phpmd-ruleset.xml index 62dcdd5..d046a71 100644 --- a/phpmd-ruleset.xml +++ b/phpmd-ruleset.xml @@ -48,7 +48,7 @@ - + diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 47e1683..a952bbf 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -50,6 +50,11 @@ public function createResponse( if (true === $varyAccept) { $response->headers->set('Vary', 'Accept'); + try { + $response->headers->set('X-Dominant-Color', $this->storage->read($path . '.color')); + } catch (\Throwable) { + // No color sidecar available - omit the header + } } $response->setMaxAge($this->ttl); $response->setExpires((new \DateTimeImmutable())->modify("+$this->ttl seconds")); diff --git a/src/Cdn.php b/src/Cdn.php index 0a604e9..44b45a5 100644 --- a/src/Cdn.php +++ b/src/Cdn.php @@ -15,15 +15,15 @@ use BaBeuloula\CdnPhp\Cache\Cache; use BaBeuloula\CdnPhp\Decoder\UriDecoder; +use BaBeuloula\CdnPhp\Exception\CdnException; use BaBeuloula\CdnPhp\Exception\EmptyUriException; -use BaBeuloula\CdnPhp\Exception\FileNotFoundException; -use BaBeuloula\CdnPhp\Exception\FileTooLargeException; use BaBeuloula\CdnPhp\Exception\InvalidUriException; use BaBeuloula\CdnPhp\Exception\NotAllowedDomainException; use BaBeuloula\CdnPhp\Exception\NotSupportedExtensionException; use BaBeuloula\CdnPhp\Processor\ImageProcessor; use BaBeuloula\CdnPhp\Processor\PathProcessor; use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor; +use BaBeuloula\CdnPhp\Security\UrlSigner; use BaBeuloula\CdnPhp\Storage\Storage; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -37,7 +37,7 @@ final class Cdn /** @var string[] */ private const array STATIC_EXTENSIONS = [ 'css', 'js', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'svg', 'ico', - 'xml', 'json', 'webmanifest', 'txt', 'map', + 'xml', 'json', 'webmanifest', 'txt', 'map', 'wasm', ]; /** @@ -53,6 +53,7 @@ public function __construct( private readonly Cache $cache, private readonly LoggerInterface $logger, private readonly string $forceToken = '', + private readonly ?UrlSigner $urlSigner = null, ) { } @@ -68,10 +69,15 @@ public function handleRequest(Request $request): Response $this->validate($decoder); } catch (EmptyUriException) { return new Response('Welcome to your CDN PHP (https://github.com/babeuloula/cdn-php)', Response::HTTP_OK); - } catch (InvalidUriException | NotSupportedExtensionException | NotAllowedDomainException $e) { + } catch (CdnException $e) { return new Response($e->getMessage(), $e->getCode()); } + $signatureError = $this->validateSignature($request, $decoder); + if (null !== $signatureError) { + return $signatureError; + } + $extension = mb_strtolower(pathinfo($decoder->getImageUrl(), PATHINFO_EXTENSION)); $isImage = \in_array($extension, self::IMAGE_EXTENSIONS, true); @@ -106,6 +112,25 @@ public function handleRequest(Request $request): Response return $this->cache->createResponse($cachedPath, $supportAvif, $supportWebp, $request, varyAccept: $isImage); } + private function validateSignature(Request $request, UriDecoder $decoder): ?Response + { + if (null === $this->urlSigner) { + return null; + } + + try { + $this->urlSigner->verify( + $decoder->getImageUrl(), + $request->query->getInt('expires'), + (string) $request->query->get('sig', ''), + ); + } catch (CdnException $e) { + return new Response($e->getMessage(), $e->getCode()); + } + + return null; + } + private function fetchAndCache( UriDecoder $decoder, string $extension, @@ -117,7 +142,7 @@ private function fetchAndCache( ): ?Response { try { $originalPath = $this->storage->fetchFile($decoder->getImageUrl(), $decoder->getDomain(), $force); - } catch (FileTooLargeException | FileNotFoundException $e) { + } catch (CdnException $e) { return new Response($e->getMessage(), $e->getCode()); } @@ -144,6 +169,11 @@ private function cacheImage( ); $this->storage->save($cachedPath, $this->storage->read($processedImage)); + $dominantColor = $this->imageProcessor->extractDominantColor($originalPath); + if (null !== $dominantColor) { + $this->storage->save($cachedPath . '.color', $dominantColor); + } + return null; } catch (\Throwable $e) { $this->logger->error( @@ -213,9 +243,7 @@ private function resolveForce(Request $request): bool /** * @throws EmptyUriException - * @throws InvalidUriException - * @throws NotAllowedDomainException - * @throws NotSupportedExtensionException + * @throws CdnException */ private function validate(UriDecoder $decoder): void { @@ -228,7 +256,7 @@ private function validate(UriDecoder $decoder): void } if (false === \in_array($decoder->getDomain(), $this->allowedDomains, true)) { - throw new NotAllowedDomainException($decoder->getDomain()); + throw new NotAllowedDomainException($decoder->getDomain()); } $extension = mb_strtolower(pathinfo($decoder->getImageUrl(), PATHINFO_EXTENSION)); diff --git a/src/Container.php b/src/Container.php index 344bc96..ec322ae 100644 --- a/src/Container.php +++ b/src/Container.php @@ -19,6 +19,8 @@ use BaBeuloula\CdnPhp\Http\HttpFetcher; use BaBeuloula\CdnPhp\Processor\ImageProcessor; use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor; +use BaBeuloula\CdnPhp\Security\SsrfValidator; +use BaBeuloula\CdnPhp\Security\UrlSigner; use BaBeuloula\CdnPhp\Storage\Storage; use Bref\Logger\StderrLogger as BrefLogger; use League\Flysystem\AwsS3V3\AwsS3V3Adapter; @@ -57,6 +59,9 @@ public function boot(): void $this->add(self::KEY_FORCE_TOKEN, $this->getEnv('FORCE_TOKEN') ?? ''); + $signatureSecret = $this->getEnv('SIGNATURE_SECRET') ?? ''; + $urlSigner = ('' !== $signatureSecret) ? new UrlSigner($signatureSecret) : null; + $this->add( Cdn::class, new Cdn( @@ -68,6 +73,7 @@ public function boot(): void $this->get(Cache::class), $this->get(LoggerInterface::class), $this->get(self::KEY_FORCE_TOKEN), + $urlSigner, ), ); } @@ -141,12 +147,14 @@ private function bootHttpFetcher(): void self::KEY_FETCH_ALLOW_REDIRECTS, true === filter_var($this->getEnv('FETCH_ALLOW_REDIRECTS') ?? '0', FILTER_VALIDATE_BOOLEAN), ); + $this->add(SsrfValidator::class, new SsrfValidator()); $this->add( HttpFetcher::class, new HttpFetcher( $this->get(self::KEY_FETCH_TIMEOUT), $this->get(self::KEY_FETCH_MAX_SIZE), $this->get(self::KEY_FETCH_ALLOW_REDIRECTS), + $this->get(SsrfValidator::class), ), ); } diff --git a/src/Exception/CdnException.php b/src/Exception/CdnException.php new file mode 100644 index 0000000..7897543 --- /dev/null +++ b/src/Exception/CdnException.php @@ -0,0 +1,18 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Exception; + +abstract class CdnException extends \RuntimeException +{ +} diff --git a/src/Exception/EmptyUriException.php b/src/Exception/EmptyUriException.php index 21818de..b5ef667 100644 --- a/src/Exception/EmptyUriException.php +++ b/src/Exception/EmptyUriException.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; -class EmptyUriException extends \LogicException +class EmptyUriException extends CdnException { public function __construct() { diff --git a/src/Exception/ExpiredUrlException.php b/src/Exception/ExpiredUrlException.php new file mode 100644 index 0000000..f1a0e20 --- /dev/null +++ b/src/Exception/ExpiredUrlException.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Exception; + +use Symfony\Component\HttpFoundation\Response; + +class ExpiredUrlException extends CdnException +{ + public function __construct() + { + parent::__construct('URL has expired.', code: Response::HTTP_GONE); + } +} diff --git a/src/Exception/FileNotFoundException.php b/src/Exception/FileNotFoundException.php index 6e1150e..52506e1 100644 --- a/src/Exception/FileNotFoundException.php +++ b/src/Exception/FileNotFoundException.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; -class FileNotFoundException extends \Exception +class FileNotFoundException extends CdnException { public function __construct(string $file, \Throwable $previous) { diff --git a/src/Exception/FileTooLargeException.php b/src/Exception/FileTooLargeException.php index 476e3e6..c643b66 100644 --- a/src/Exception/FileTooLargeException.php +++ b/src/Exception/FileTooLargeException.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; -class FileTooLargeException extends \Exception +class FileTooLargeException extends CdnException { public function __construct(string $url, int $maxBytes) { diff --git a/src/Exception/InvalidSignatureException.php b/src/Exception/InvalidSignatureException.php new file mode 100644 index 0000000..e3d9b22 --- /dev/null +++ b/src/Exception/InvalidSignatureException.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Exception; + +use Symfony\Component\HttpFoundation\Response; + +class InvalidSignatureException extends CdnException +{ + public function __construct() + { + parent::__construct('Invalid or missing URL signature.', code: Response::HTTP_FORBIDDEN); + } +} diff --git a/src/Exception/InvalidUriException.php b/src/Exception/InvalidUriException.php index 7f813e6..41d3ebc 100644 --- a/src/Exception/InvalidUriException.php +++ b/src/Exception/InvalidUriException.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; -class InvalidUriException extends \InvalidArgumentException +class InvalidUriException extends CdnException { public function __construct(string $uri) { diff --git a/src/Exception/NotAllowedDomainException.php b/src/Exception/NotAllowedDomainException.php index df8850a..6d4cf2c 100644 --- a/src/Exception/NotAllowedDomainException.php +++ b/src/Exception/NotAllowedDomainException.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; -class NotAllowedDomainException extends \InvalidArgumentException +class NotAllowedDomainException extends CdnException { public function __construct(string $domain) { diff --git a/src/Exception/NotSupportedExtensionException.php b/src/Exception/NotSupportedExtensionException.php index 2884c4c..ab70b90 100644 --- a/src/Exception/NotSupportedExtensionException.php +++ b/src/Exception/NotSupportedExtensionException.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; -class NotSupportedExtensionException extends \InvalidArgumentException +class NotSupportedExtensionException extends CdnException { public function __construct(string $extension) { diff --git a/src/Exception/SsrfAttemptException.php b/src/Exception/SsrfAttemptException.php new file mode 100644 index 0000000..3cd0648 --- /dev/null +++ b/src/Exception/SsrfAttemptException.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Exception; + +use Symfony\Component\HttpFoundation\Response; + +class SsrfAttemptException extends CdnException +{ + public function __construct(string $url) + { + parent::__construct("SSRF attempt blocked: {$url}", code: Response::HTTP_FORBIDDEN); + } +} diff --git a/src/Http/HttpFetcher.php b/src/Http/HttpFetcher.php index 6b99816..b83c8d4 100644 --- a/src/Http/HttpFetcher.php +++ b/src/Http/HttpFetcher.php @@ -14,6 +14,8 @@ namespace BaBeuloula\CdnPhp\Http; use BaBeuloula\CdnPhp\Exception\FileTooLargeException; +use BaBeuloula\CdnPhp\Exception\SsrfAttemptException; +use BaBeuloula\CdnPhp\Security\SsrfValidator; class HttpFetcher { @@ -21,15 +23,21 @@ public function __construct( private readonly int $timeout, private readonly int $maxBytes, private readonly bool $allowRedirects = false, + private readonly ?SsrfValidator $ssrfValidator = null, ) { } /** * @throws \RuntimeException * @throws FileTooLargeException + * @throws SsrfAttemptException */ public function fetch(string $url): string { + if (null !== $this->ssrfValidator) { + $this->ssrfValidator->assertSafe($url); + } + $followLocation = (true === $this->allowRedirects) ? 1 : 0; $context = stream_context_create( [ diff --git a/src/Processor/ImageProcessor.php b/src/Processor/ImageProcessor.php index a15d1aa..c2f07d9 100644 --- a/src/Processor/ImageProcessor.php +++ b/src/Processor/ImageProcessor.php @@ -78,7 +78,40 @@ public function process( ] ); - return $server->makeImage(basename($path), $glideParams); + $cachePath = $server->makeImage(basename($path), $glideParams); + $this->stripExif($cachePath); + + return $cachePath; + } + + public function extractDominantColor(string $path): ?string + { + try { + $imagick = new \Imagick(); + $imagick->readImageBlob($this->adapter->read($path)); + $imagick->resizeImage(1, 1, \Imagick::FILTER_LANCZOS, 1); + $pixel = $imagick->getImagePixelColor(0, 0); + $color = $pixel->getColor(); + $imagick->clear(); + + return sprintf('#%02x%02x%02x', $color['r'], $color['g'], $color['b']); + } catch (\Throwable) { + return null; + } + } + + private function stripExif(string $path): void + { + try { + $content = $this->adapter->read($path); + $imagick = new \Imagick(); + $imagick->readImageBlob($content); + $imagick->stripImage(); + $this->adapter->write($path, $imagick->getImagesBlob(), new Config()); + $imagick->clear(); + } catch (\Throwable) { + // Non-blocking: EXIF stripping failure must not prevent serving the image + } } private function processAnimated(string $path, QueryParams $params, bool $outputWebp = false): string @@ -109,6 +142,7 @@ private function processAnimated(string $path, QueryParams $params, bool $output $animation->setFormat('WEBP'); } + $animation->stripImage(); $blob = $animation->getImagesBlob(); $animation->clear(); $imagick->clear(); diff --git a/src/Processor/PathProcessor.php b/src/Processor/PathProcessor.php index 511e08e..73e69b4 100644 --- a/src/Processor/PathProcessor.php +++ b/src/Processor/PathProcessor.php @@ -58,6 +58,8 @@ private function generatePath(): void $params['mark'] = (new AsciiSlugger())->slug($this->decoder->getParams()->watermarkUrl)->toString(); } + ksort($params); + $parts = []; foreach ($params as $key => $value) { $parts[] = "{$key}{$value}"; diff --git a/src/Processor/StaticAssetProcessor.php b/src/Processor/StaticAssetProcessor.php index 26c38a3..7bb2f02 100644 --- a/src/Processor/StaticAssetProcessor.php +++ b/src/Processor/StaticAssetProcessor.php @@ -21,6 +21,7 @@ final class StaticAssetProcessor { private const array MINIFIABLE_CSS = ['css']; private const array MINIFIABLE_JS = ['js']; + private const array MINIFIABLE_JSON = ['json', 'webmanifest']; public function __construct( private readonly FilesystemAdapter $adapter, @@ -41,6 +42,10 @@ public function process(string $path, string $extension): string $minifier = new Minify\JS($content); $content = $minifier->minify(); $this->logger->info('Minified JS: {path}', ['path' => $path]); + } elseif (true === \in_array($extension, self::MINIFIABLE_JSON, true)) { + $decoded = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + $content = json_encode($decoded, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES); + $this->logger->info('Minified JSON: {path}', ['path' => $path]); } return $content; diff --git a/src/Security/SsrfValidator.php b/src/Security/SsrfValidator.php new file mode 100644 index 0000000..bb340f9 --- /dev/null +++ b/src/Security/SsrfValidator.php @@ -0,0 +1,86 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Security; + +use BaBeuloula\CdnPhp\Exception\SsrfAttemptException; + +final class SsrfValidator +{ + /** + * Private/reserved IPv4 CIDR ranges that must never be reached. + * + * @var array + */ + private const array BLOCKED_CIDRS = [ + ['0.0.0.0', 8], // Unspecified + ['10.0.0.0', 8], // RFC 1918 private + ['100.64.0.0', 10], // Shared address space + ['127.0.0.0', 8], // Loopback + ['169.254.0.0', 16], // Link-local / AWS EC2 metadata + ['172.16.0.0', 12], // RFC 1918 private + ['192.168.0.0', 16], // RFC 1918 private + ['198.18.0.0', 15], // Benchmarking + ['240.0.0.0', 4], // Reserved + ]; + + /** @throws SsrfAttemptException */ + public function assertSafe(string $url): void + { + $hostname = (string) parse_url($url, PHP_URL_HOST); + + if ('' === $hostname) { + throw new SsrfAttemptException($url); + } + + // If the hostname is already an IP, validate it directly without DNS resolution + if (false !== filter_var($hostname, FILTER_VALIDATE_IP)) { + if (true === $this->isPrivateIp($hostname)) { + throw new SsrfAttemptException($url); + } + + return; + } + + $ipAddress = gethostbyname($hostname); + + if ($ipAddress === $hostname) { + // gethostbyname returns the hostname unchanged when resolution fails + throw new SsrfAttemptException($url); + } + + if (true === $this->isPrivateIp($ipAddress)) { + throw new SsrfAttemptException($url); + } + } + + private function isPrivateIp(string $ipAddress): bool + { + $ipLong = ip2long($ipAddress); + + if (false === $ipLong) { + return true; + } + + foreach (self::BLOCKED_CIDRS as [$network, $prefix]) { + $networkLong = ip2long($network); + $mask = ~((1 << (32 - $prefix)) - 1); + + if (false !== $networkLong && ($ipLong & $mask) === ($networkLong & $mask)) { + return true; + } + } + + return false; + } +} diff --git a/src/Security/UrlSigner.php b/src/Security/UrlSigner.php new file mode 100644 index 0000000..2a9da21 --- /dev/null +++ b/src/Security/UrlSigner.php @@ -0,0 +1,45 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Security; + +use BaBeuloula\CdnPhp\Exception\ExpiredUrlException; +use BaBeuloula\CdnPhp\Exception\InvalidSignatureException; + +final class UrlSigner +{ + public function __construct(private readonly string $secret) + { + } + + /** + * @throws ExpiredUrlException + * @throws InvalidSignatureException + */ + public function verify(string $imageUrl, int $expires, string $sig): void + { + if (0 === $expires || '' === $sig) { + throw new InvalidSignatureException(); + } + + if ($expires < time()) { + throw new ExpiredUrlException(); + } + + $expected = hash_hmac('sha256', $imageUrl . ':' . $expires, $this->secret); + + if (false === hash_equals($expected, $sig)) { + throw new InvalidSignatureException(); + } + } +} diff --git a/tests/Cdn/CdnTest.php b/tests/Cdn/CdnTest.php index 8b912b8..613dfcb 100644 --- a/tests/Cdn/CdnTest.php +++ b/tests/Cdn/CdnTest.php @@ -13,10 +13,16 @@ namespace BaBeuloula\CdnPhp\Tests\Cdn; +use BaBeuloula\CdnPhp\Cache\Cache; use BaBeuloula\CdnPhp\Cdn; +use BaBeuloula\CdnPhp\Processor\ImageProcessor; +use BaBeuloula\CdnPhp\Processor\StaticAssetProcessor; +use BaBeuloula\CdnPhp\Security\UrlSigner; +use BaBeuloula\CdnPhp\Storage\Storage; use BaBeuloula\CdnPhp\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -331,7 +337,7 @@ public function avifOutputTakesPriorityOverWebp(): void #[Test] public function canRecognizeHeicExtensionAsValidImageFormat(): void { - // HEIC is a valid image extension — must not be rejected with 400 (unsupported extension) + // HEIC is a valid image extension - must not be rejected with 400 (unsupported extension) $request = Request::create('http://mycdn.com/' . static::TEST_HEIC_URI); $response = $this->cdn->handleRequest($request); @@ -382,4 +388,109 @@ public function canHandleRequestWithMapAsset(): void static::assertSame(Response::HTTP_OK, $response->getStatusCode()); static::assertNull($response->headers->get('Vary')); } + + #[Test] + public function canHandleRequestWithWasmAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_WASM_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertNull($response->headers->get('Vary')); + } + + #[Test] + public function canHandleRequestWithDominantColorHeader(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $color = $response->headers->get('X-Dominant-Color'); + static::assertNotNull($color); + static::assertMatchesRegularExpression('/^#[0-9a-f]{6}$/', (string) $color); + } + + #[Test] + public function cantHandleRequestWithCorruptJsonAsset(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_CORRUPT_JSON_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + } + + #[Test] + public function canHandleRequestWithSignedUrl(): void + { + $expires = time() + 3600; + $sig = hash_hmac('sha256', static::TEST_BASE_URI . ':' . $expires, static::TEST_FORCE_TOKEN); + $params = http_build_query(['expires' => $expires, 'sig' => $sig]); + + $cdn = $this->getCdnWithSignatureSecret(static::TEST_FORCE_TOKEN); + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI . '?' . $params); + + $response = $cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + #[Test] + public function cantHandleRequestWithInvalidSignature(): void + { + $expires = time() + 3600; + $params = http_build_query(['expires' => $expires, 'sig' => 'wrong-signature']); + + $cdn = $this->getCdnWithSignatureSecret(static::TEST_FORCE_TOKEN); + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI . '?' . $params); + + $response = $cdn->handleRequest($request); + + static::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + + #[Test] + public function cantHandleRequestWithExpiredSignedUrl(): void + { + $expires = time() - 1; + $sig = hash_hmac('sha256', static::TEST_BASE_URI . ':' . $expires, static::TEST_FORCE_TOKEN); + $params = http_build_query(['expires' => $expires, 'sig' => $sig]); + + $cdn = $this->getCdnWithSignatureSecret(static::TEST_FORCE_TOKEN); + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI . '?' . $params); + + $response = $cdn->handleRequest($request); + + static::assertSame(Response::HTTP_GONE, $response->getStatusCode()); + } + + #[Test] + public function skipsSignatureCheckWhenSecretIsEmpty(): void + { + // Default CDN has no signature secret - any request must pass without expires/sig + $request = Request::create('http://mycdn.com/' . static::TEST_BASE_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + private function getCdnWithSignatureSecret(string $secret): Cdn + { + return new Cdn( + $this->getContainer('allowed_domains'), + $this->getContainer('domains_aliases'), + $this->getContainer(Storage::class), + $this->getContainer(ImageProcessor::class), + $this->getContainer(StaticAssetProcessor::class), + $this->getContainer(Cache::class), + $this->getContainer(LoggerInterface::class), + '', + new UrlSigner($secret), + ); + } } diff --git a/tests/Processor/ImageProcessorTest.php b/tests/Processor/ImageProcessorTest.php index 2c6a191..2287ed9 100644 --- a/tests/Processor/ImageProcessorTest.php +++ b/tests/Processor/ImageProcessorTest.php @@ -125,6 +125,27 @@ public function canProcessAnimatedGifAsWebp(): void $imagick->clear(); } + #[Test] + public function canExtractDominantColor(): void + { + /** @var ImageProcessor $imageProcessor */ + $imageProcessor = $this->getContainer(ImageProcessor::class); + + $color = $imageProcessor->extractDominantColor(static::TEST_FILENAME); + + static::assertNotNull($color); + static::assertMatchesRegularExpression('/^#[0-9a-f]{6}$/', $color); + } + + #[Test] + public function extractDominantColorReturnsNullForNonExistentFile(): void + { + /** @var ImageProcessor $imageProcessor */ + $imageProcessor = $this->getContainer(ImageProcessor::class); + + static::assertNull($imageProcessor->extractDominantColor('nonexistent.jpg')); + } + #[Test] public function canProcessAnimatedWebp(): void { diff --git a/tests/Processor/PathProcessorTest.php b/tests/Processor/PathProcessorTest.php index 987c72c..9724437 100644 --- a/tests/Processor/PathProcessorTest.php +++ b/tests/Processor/PathProcessorTest.php @@ -55,15 +55,15 @@ public static function queryParametersProvider(): \Generator { yield ['w0', []]; yield ['w100', ['w' => 100]]; - yield ['w0/h100', ['h' => 100]]; + yield ['h100/w0', ['h' => 100]]; yield [ - 'w0/markexample-com-watermark-jpg/markposcenter/markw75w/markalpha50', + 'markexample-com-watermark-jpg/markalpha50/markposcenter/markw75w/w0', ['wu' => static::TEST_WATERMARK_URL], ]; yield ['w0', ['ws' => 50]]; yield ['w0', ['wo' => 50]]; - yield ['w100/h100', ['w' => 100, 'h' => 100]]; + yield ['h100/w100', ['w' => 100, 'h' => 100]]; } #[DataProvider('queryParametersProvider')] diff --git a/tests/Processor/StaticAssetProcessorTest.php b/tests/Processor/StaticAssetProcessorTest.php index 4151cb0..b8875e8 100644 --- a/tests/Processor/StaticAssetProcessorTest.php +++ b/tests/Processor/StaticAssetProcessorTest.php @@ -35,6 +35,8 @@ protected function setUp(): void $this->adapter->write(static::TEST_CSS_FILENAME, static::getTestCssContent(), new Config()); $this->adapter->write(static::TEST_JS_FILENAME, static::getTestJsContent(), new Config()); $this->adapter->write(static::TEST_WOFF2_FILENAME, static::getTestFontContent(), new Config()); + $this->adapter->write('manifest.json', static::getTestJsonContent(), new Config()); + $this->adapter->write('app.webmanifest', static::getTestWebmanifestContent(), new Config()); /** @var StaticAssetProcessor $processor */ $processor = $this->getContainer(StaticAssetProcessor::class); @@ -76,4 +78,22 @@ public function canPassthroughFontAsset(): void static::assertSame(static::getTestFontContent(), $result); } + + #[Test] + public function canMinifyJson(): void + { + $result = $this->processor->process('manifest.json', 'json'); + + static::assertStringNotContainsString("\n", $result); + static::assertStringContainsString('"name":', $result); + } + + #[Test] + public function canMinifyWebmanifest(): void + { + $result = $this->processor->process('app.webmanifest', 'webmanifest'); + + static::assertStringNotContainsString("\n", $result); + static::assertStringContainsString('"name":', $result); + } } diff --git a/tests/Security/SsrfValidatorTest.php b/tests/Security/SsrfValidatorTest.php new file mode 100644 index 0000000..aaf4707 --- /dev/null +++ b/tests/Security/SsrfValidatorTest.php @@ -0,0 +1,87 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Tests\Security; + +use BaBeuloula\CdnPhp\Exception\SsrfAttemptException; +use BaBeuloula\CdnPhp\Security\SsrfValidator; +use BaBeuloula\CdnPhp\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; + +class SsrfValidatorTest extends TestCase +{ + private SsrfValidator $validator; + + protected function setUp(): void + { + parent::setUp(); + + $this->validator = new SsrfValidator(); + } + + #[DataProvider('privateIpProvider')] + #[Test] + public function blocksPrivateIp(string $ip): void + { + static::expectException(SsrfAttemptException::class); + + $this->validator->assertSafe("http://{$ip}/file.jpg"); + } + + public static function privateIpProvider(): \Generator + { + yield 'loopback' => ['127.0.0.1']; + yield 'loopback-other' => ['127.0.0.2']; + yield 'link-local (AWS metadata)' => ['169.254.169.254']; + yield 'private-10' => ['10.0.0.1']; + yield 'private-172' => ['172.16.0.1']; + yield 'private-192' => ['192.168.1.1']; + yield 'unspecified' => ['0.0.0.0']; + } + + #[Test] + public function allowsPublicIp(): void + { + // 93.184.216.34 is example.com's public IP - must not throw + $this->expectNotToPerformAssertions(); + $this->validator->assertSafe('https://93.184.216.34/image.jpg'); + } + + #[Test] + public function blocksEmptyHostname(): void + { + static::expectException(SsrfAttemptException::class); + + // http:///path has no host - parse_url returns '' + $this->validator->assertSafe('http:///secret-file.jpg'); + } + + #[Test] + public function blocksUnresolvableDomain(): void + { + static::expectException(SsrfAttemptException::class); + + // .invalid TLD is IANA-reserved and will never resolve + $this->validator->assertSafe('http://this-host-does-not-exist.invalid/file.jpg'); + } + + #[Test] + public function blocksIPv6Address(): void + { + static::expectException(SsrfAttemptException::class); + + // IPv6 addresses cannot be validated via ip2long (IPv4-only) - treated as private + $this->validator->assertSafe('http://[::1]/file.jpg'); + } +} diff --git a/tests/Security/UrlSignerTest.php b/tests/Security/UrlSignerTest.php new file mode 100644 index 0000000..20e508a --- /dev/null +++ b/tests/Security/UrlSignerTest.php @@ -0,0 +1,72 @@ + + * @copyright Copyright (c) BaBeuloula + * @license MIT + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace BaBeuloula\CdnPhp\Tests\Security; + +use BaBeuloula\CdnPhp\Exception\ExpiredUrlException; +use BaBeuloula\CdnPhp\Exception\InvalidSignatureException; +use BaBeuloula\CdnPhp\Security\UrlSigner; +use BaBeuloula\CdnPhp\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; + +class UrlSignerTest extends TestCase +{ + private const string SECRET = 'test-secret'; + private const string IMAGE_URL = 'https://example.com/image.jpg'; + private UrlSigner $signer; + + protected function setUp(): void + { + parent::setUp(); + + $this->signer = new UrlSigner(self::SECRET); + } + + #[Test] + public function acceptsValidSignature(): void + { + $expires = time() + 3600; + $sig = hash_hmac('sha256', self::IMAGE_URL . ':' . $expires, self::SECRET); + + $this->expectNotToPerformAssertions(); + $this->signer->verify(self::IMAGE_URL, $expires, $sig); + } + + #[Test] + public function rejectsInvalidSignature(): void + { + static::expectException(InvalidSignatureException::class); + + $expires = time() + 3600; + $this->signer->verify(self::IMAGE_URL, $expires, 'wrong-sig'); + } + + #[Test] + public function rejectsMissingSignature(): void + { + static::expectException(InvalidSignatureException::class); + + $this->signer->verify(self::IMAGE_URL, 0, ''); + } + + #[Test] + public function rejectsExpiredUrl(): void + { + static::expectException(ExpiredUrlException::class); + + $expires = time() - 1; + $sig = hash_hmac('sha256', self::IMAGE_URL . ':' . $expires, self::SECRET); + + $this->signer->verify(self::IMAGE_URL, $expires, $sig); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index c383b96..a3ddf46 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -54,6 +54,8 @@ class TestCase extends BaseTestCase protected const string TEST_TXT_URI = 'https://example.com/robots.txt'; protected const string TEST_MAP_URI = 'https://example.com/app.js.map'; protected const string TEST_HEIC_URI = 'https://example.com/photo.heic'; + protected const string TEST_WASM_URI = 'https://example.com/module.wasm'; + protected const string TEST_CORRUPT_JSON_URI = 'https://example.com/broken.json'; private Container $container; @@ -119,6 +121,14 @@ static function (string $url) { return static::getTestMapContent(); } + if (static::TEST_WASM_URI === $url) { + return static::getTestWasmContent(); + } + + if (static::TEST_CORRUPT_JSON_URI === $url) { + return '{not valid json'; + } + throw new \RuntimeException("URL not mocked: {$url}"); }, ) @@ -175,12 +185,12 @@ protected static function getTestXmlContent(): string protected static function getTestJsonContent(): string { - return '{"name":"My App","version":"1.0"}'; + return "{\n \"name\": \"App\",\n \"version\": \"1\"\n}"; } protected static function getTestWebmanifestContent(): string { - return '{"name":"My App","icons":[]}'; + return "{\n \"name\": \"App\",\n \"icons\": []\n}"; } protected static function getTestTxtContent(): string @@ -193,6 +203,11 @@ protected static function getTestMapContent(): string return '{"version":3,"sources":["app.js"],"mappings":""}'; } + protected static function getTestWasmContent(): string + { + return "\x00asm\x01\x00\x00\x00"; + } + protected static function getTestAnimatedGifContent(): string { $animation = new \Imagick();