From be32156f5cd4f8d8a045fff09c0d7e3740189b1d Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Tue, 18 Feb 2020 14:14:24 -0600 Subject: [PATCH] Add middleware with tests and code style tooling --- .gitignore | 3 + .idea/$CACHE_FILE$ | 6 + .idea/.gitignore | 24 +++ .idea/encodings.xml | 6 + .idea/inspectionProfiles/Project_Default.xml | 153 +++++++++++++++++++ .idea/laravel-samesite-none-compat.iml | 84 ++++++++++ .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/php.xml | 90 +++++++++++ .idea/vcs.xml | 6 + .idea/watcherTasks.xml | 45 ++++++ .php_cs | 13 ++ README.md | 4 +- composer.json | 8 +- phpcs.xml | 24 +++ phpunit.xml | 23 +++ src/SameSiteNoneMiddleware.php | 111 ++++++++++++++ tests/SameSiteNoneMiddlewareTest.php | 125 +++++++++++++++ 18 files changed, 736 insertions(+), 3 deletions(-) create mode 100644 .idea/$CACHE_FILE$ create mode 100644 .idea/.gitignore create mode 100644 .idea/encodings.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/laravel-samesite-none-compat.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/php.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/watcherTasks.xml create mode 100644 .php_cs create mode 100644 phpcs.xml create mode 100644 phpunit.xml create mode 100644 src/SameSiteNoneMiddleware.php create mode 100644 tests/SameSiteNoneMiddlewareTest.php diff --git a/.gitignore b/.gitignore index c8153b5..5bf7c32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /composer.lock /vendor/ +.phpunit.result.cache +.phpcs-cache +.php_cs.cache diff --git a/.idea/$CACHE_FILE$ b/.idea/$CACHE_FILE$ new file mode 100644 index 0000000..6cb8985 --- /dev/null +++ b/.idea/$CACHE_FILE$ @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..44935f0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,24 @@ +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +/workspace.xml +/tasks.xml +/usage.statistics.xml +/dictionaries +/shelf + +# Generated files +/contentModel.xml + +# Sensitive or high-churn files +/dataSources/ +/dataSources.ids +/dataSources.local.xml +/sqlDataSources.xml +/dynamic.xml +/uiDesigner.xml +/dbnavigator.xml + +# Editor-based Rest Client +/httpRequests diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..97626ba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..a25064c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,153 @@ + + + + \ No newline at end of file diff --git a/.idea/laravel-samesite-none-compat.iml b/.idea/laravel-samesite-none-compat.iml new file mode 100644 index 0000000..fbe5190 --- /dev/null +++ b/.idea/laravel-samesite-none-compat.iml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..28a804d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d74c482 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..f267d95 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml new file mode 100644 index 0000000..6fac539 --- /dev/null +++ b/.idea/watcherTasks.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..69a3e21 --- /dev/null +++ b/.php_cs @@ -0,0 +1,13 @@ +in(__DIR__); + +return PhpCsFixer\Config::create() + ->setRules( + [ + 'mb_str_functions' => true, + ] + ) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/README.md b/README.md index e02cdd7..9fe76d7 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,10 @@ appcookie=value; SameSite=None; Secure This package will add a this fallback cookie in the response that gets sent back to the browser: ``` -appcookie__ssn-legacy=value; Secure +appcookie__ssn-fallback=value; Secure ``` -When your app receives an incoming request, this package will inspect the cookies and promote any fallback cookies that don't have a standard counterpart marked as `SameSite=None; Secure`. +When your app receives an incoming request, this package will inspect the cookies and promote any fallback cookies that don't already have a primary counterpart marked as `SameSite=None; Secure`. ## License diff --git a/composer.json b/composer.json index f866493..0b35c29 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,12 @@ "sort-packages": true }, "require": { - "php": ">=7.2" + "php": ">=7.2", + "illuminate/http": "^5.8|^6" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "friendsofphp/php-cs-fixer": "^2.16", + "phpunit/phpunit": "^8.5" } } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..9c65345 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + src + tests + + + + + + + + + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..889dec3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + + src + + + diff --git a/src/SameSiteNoneMiddleware.php b/src/SameSiteNoneMiddleware.php new file mode 100644 index 0000000..3099bcb --- /dev/null +++ b/src/SameSiteNoneMiddleware.php @@ -0,0 +1,111 @@ +promoteFallbackCookies($request); + + /** @var Response $response */ + $response = $next($request); + + return $this->setFallbackCookies($response); + } + + private function promoteFallbackCookies(Request $request) : Request + { + foreach ($this->getFallbackCookies($request) as $fallbackCookieName => $fallbackCookieValue) { + $primaryCookieName = $this->convertToPrimaryCookieName($fallbackCookieName); + + if (! $request->cookies->has($primaryCookieName)) { + $request->cookies->add([$primaryCookieName => $fallbackCookieValue]); + } + + $request->cookies->remove($fallbackCookieName); + } + + return $request; + } + + private function getFallbackCookies(Request $request) : array + { + return array_filter( + $request->cookies->all(), + function (string $name) { + return $this->cookieNameHasFallbackSuffix($name); + }, + ARRAY_FILTER_USE_KEY + ); + } + + private function cookieNameHasFallbackSuffix(string $cookieName) : bool + { + return mb_substr($cookieName, -mb_strlen($this->fallbackSuffix)) === $this->fallbackSuffix; + } + + private function convertToPrimaryCookieName(string $fallbackCookieName) : string + { + return mb_substr($fallbackCookieName, 0, -mb_strlen($this->fallbackSuffix)); + } + + private function setFallbackCookies(Response $response) : Response + { + foreach ($this->getSameSiteNoneCookies($response) as $cookie) { + $response->headers->setCookie($this->convertToFallbackCookie($cookie)); + } + + return $response; + } + + private function getSameSiteNoneCookies(Response $response) : array + { + return array_values( + array_filter( + $response->headers->getCookies(), + static function (Cookie $cookie) { + return mb_strtolower($cookie->getSameSite() ?? '') === 'none'; + } + ) + ); + } + + private function convertToFallbackCookie(Cookie $cookie) : Cookie + { + return new Cookie( + $cookie->getName() . $this->fallbackSuffix, + $cookie->getValue(), + $cookie->getExpiresTime(), + $cookie->getPath(), + $cookie->getDomain(), + $cookie->isSecure(), + $cookie->isHttpOnly(), + $cookie->isRaw(), + null + ); + } +} diff --git a/tests/SameSiteNoneMiddlewareTest.php b/tests/SameSiteNoneMiddlewareTest.php new file mode 100644 index 0000000..e1082bf --- /dev/null +++ b/tests/SameSiteNoneMiddlewareTest.php @@ -0,0 +1,125 @@ +middleware = new SameSiteNoneMiddleware(); + $this->request = Request::create('/'); + $this->response = Response::create(); + } + + /** + * @test + */ + public function promoteIncomingFallbackCookies() : void + { + $this->addCookieToRequest('cookie_1', '3QZX2D0iBatn9PIXcP7W'); + $this->addCookieToRequest('cookie_2', 'kpahhaiIrQF9ywdcuQtD'); + $this->addCookieToRequest('cookie_2', 'kpahhaiIrQF9ywdcuQtD', true); + $this->addCookieToRequest('cookie_3', 'NzYYWZ1XXvaqJh4Fnn1H', true); + + $processedRequest = null; + + $this->middleware->handle($this->request, function (Request $request) use (&$processedRequest) : Response { + $processedRequest = $request; + + return $this->response; + }); + + /** @var Request $processedRequest */ + self::assertInstanceOf(Request::class, $processedRequest); + + self::assertCount(3, $processedRequest->cookies->all()); + self::assertSame('3QZX2D0iBatn9PIXcP7W', $processedRequest->cookies->get('cookie_1')); + self::assertSame('kpahhaiIrQF9ywdcuQtD', $processedRequest->cookies->get('cookie_2')); + self::assertSame('NzYYWZ1XXvaqJh4Fnn1H', $processedRequest->cookies->get('cookie_3')); + self::assertFalse($processedRequest->cookies->has('cookie_2' . $this->fallbackSuffix)); + self::assertFalse($processedRequest->cookies->has('cookie_3' . $this->fallbackSuffix)); + } + + /** + * @test + */ + public function createOutgoingFallbackCookies() : void + { + $this->addCookieToResponse('cookie_1', 'dUuEjdsIYk86iIgiKFro'); + $this->addCookieToResponse('cookie_2', 'wprRSHWj5V2CY2v1oNyv', true); + $this->addCookieToResponse('cookie_3', 'i3rXl5HLRg3V0yJySvN9', true); + $this->addCookieToResponse('cookie_4', '0PitdbGuHYrX5Yz7WhF0'); + + $response = $this->middleware->handle($this->request, function () : Response { + return $this->response; + }); + + /** @var Cookie[] $responseCookies */ + $responseCookies = $response->headers->getCookies(ResponseHeaderBag::COOKIES_ARRAY)['example.com']['/']; + + self::assertCount(6, $responseCookies); + self::assertSame('dUuEjdsIYk86iIgiKFro', $responseCookies['cookie_1']->getValue()); + + self::assertSame('wprRSHWj5V2CY2v1oNyv', $responseCookies['cookie_2']->getValue()); + self::assertSame('none', mb_strtolower($responseCookies['cookie_2']->getSameSite())); + self::assertSame('wprRSHWj5V2CY2v1oNyv', $responseCookies['cookie_2' . $this->fallbackSuffix]->getValue()); + self::assertNull($responseCookies['cookie_2' . $this->fallbackSuffix]->getSameSite()); + + self::assertSame('i3rXl5HLRg3V0yJySvN9', $responseCookies['cookie_3']->getValue()); + self::assertSame('none', mb_strtolower($responseCookies['cookie_3']->getSameSite())); + self::assertSame('i3rXl5HLRg3V0yJySvN9', $responseCookies['cookie_3' . $this->fallbackSuffix]->getValue()); + self::assertNull($responseCookies['cookie_3' . $this->fallbackSuffix]->getSameSite()); + + self::assertSame('0PitdbGuHYrX5Yz7WhF0', $responseCookies['cookie_4']->getValue()); + } + + private function addCookieToRequest(string $name, string $value, bool $isFallback = false) : void + { + if ($isFallback) { + $name .= $this->fallbackSuffix; + } + + $this->request->cookies->add([$name => $value]); + } + + private function addCookieToResponse(string $name, string $value, bool $isSameSiteNone = false) : void + { + $this->response->headers->setCookie( + new Cookie( + $name, + $value, + 0, + '/', + 'example.com', + $isSameSiteNone, + false, + false, + $isSameSiteNone ? 'None' : null + ) + ); + } +}