ソースを参照

Add support for specifying "SameSite" attribute for cookies.

ADmad 5 年 前
コミット
30be57b46f

+ 64 - 1
src/Http/Cookie/Cookie.php

@@ -104,6 +104,13 @@ class Cookie implements CookieInterface
     protected $httpOnly = false;
 
     /**
+     * Samesite
+     *
+     * @var string|null
+     */
+    protected $sameSite = null;
+
+    /**
      * Constructor
      *
      * The constructors args are similar to the native PHP `setcookie()` method.
@@ -118,6 +125,7 @@ class Cookie implements CookieInterface
      * @param string $domain Domain
      * @param bool $secure Is secure
      * @param bool $httpOnly HTTP Only
+     * @param string|null $sameSite Samesite
      */
     public function __construct(
         $name,
@@ -126,7 +134,8 @@ class Cookie implements CookieInterface
         $path = '/',
         $domain = '',
         $secure = false,
-        $httpOnly = false
+        $httpOnly = false,
+        $sameSite = null
     ) {
         $this->validateName($name);
         $this->name = $name;
@@ -144,6 +153,12 @@ class Cookie implements CookieInterface
 
         $this->validateBool($secure);
         $this->secure = $secure;
+
+        if ($sameSite) {
+            $this->validateSameSiteValue($sameSite);
+            $this->sameSite = $sameSite;
+        }
+
         if ($expiresAt) {
             $expiresAt = $expiresAt->setTimezone(new DateTimeZone('GMT'));
         }
@@ -169,6 +184,9 @@ class Cookie implements CookieInterface
         if ($this->path !== '') {
             $headerValue[] = sprintf('path=%s', $this->path);
         }
+        if ($this->sameSite) {
+            $headerValue[] = sprintf('samesite=%s', $this->sameSite);
+        }
         if ($this->domain !== '') {
             $headerValue[] = sprintf('domain=%s', $this->domain);
         }
@@ -467,6 +485,51 @@ class Cookie implements CookieInterface
     }
 
     /**
+     * Get the SameSite attribute.
+     *
+     * @return string|null
+     */
+    public function getSameSite()
+    {
+        return $this->sameSite;
+    }
+
+    /**
+     * Create a cookie with an updated SameSite option.
+     *
+     * @param string|null $sameSite Value for to set for Samesite option.
+     *   One of CookieInterface::SAMESITE_* constants.
+     * @return static
+     */
+    public function withSameSite($sameSite = null)
+    {
+        if ($sameSite !== null) {
+            $this->validateSameSiteValue($sameSite);
+        }
+
+        $new = clone $this;
+        $new->sameSite = $sameSite;
+
+        return $new;
+    }
+
+    /**
+     * Check that value passed for SameSite is valid.
+     *
+     * @param string $sameSite SameSite value
+     * @return void
+     * @throws \InvalidArgumentException
+     */
+    protected static function validateSameSiteValue($sameSite)
+    {
+        if (!in_array($sameSite, CookieInterface::SAMESITE_VALUES, true)) {
+            throw new InvalidArgumentException(
+                'Samesite value must be either of: ' . implode(', ', CookieInterface::SAMESITE_VALUES)
+            );
+        }
+    }
+
+    /**
      * Checks if a value exists in the cookie data.
      *
      * This method will expand serialized complex data,

+ 35 - 0
src/Http/Cookie/CookieInterface.php

@@ -15,6 +15,9 @@ namespace Cake\Http\Cookie;
 
 /**
  * Cookie Interface
+ *
+ * @method string|null getSameSite()
+ * @method static withSameSite($sameSite = null)
  */
 interface CookieInterface
 {
@@ -26,6 +29,38 @@ interface CookieInterface
     const EXPIRES_FORMAT = 'D, d-M-Y H:i:s T';
 
     /**
+     * SameSite attribute value: Lax
+     *
+     * @var string
+     */
+    const SAMESITE_LAX = 'Lax';
+
+    /**
+     * SameSite attribute value: Strict
+     *
+     * @var string
+     */
+    const SAMESITE_STRICT = 'Strict';
+
+    /**
+     * SameSite attribute value: None
+     *
+     * @var string
+     */
+    const SAMESITE_NONE = 'None';
+
+    /**
+     * Valid values for "SameSite" attribute.
+     *
+     * @var string[]
+     */
+    const SAMESITE_VALUES = [
+        self::SAMESITE_LAX,
+        self::SAMESITE_STRICT,
+        self::SAMESITE_NONE,
+    ];
+
+    /**
      * Sets the cookie name
      *
      * @param string $name Name of the cookie

+ 4 - 1
src/Http/Response.php

@@ -2271,6 +2271,7 @@ class Response implements ResponseInterface
                 'domain' => '',
                 'secure' => false,
                 'httpOnly' => false,
+                'samesite' => null,
             ];
             $expires = $data['expire'] ? new DateTime('@' . $data['expire']) : null;
             $cookie = new Cookie(
@@ -2280,7 +2281,8 @@ class Response implements ResponseInterface
                 $data['path'],
                 $data['domain'],
                 $data['secure'],
-                $data['httpOnly']
+                $data['httpOnly'],
+                $data['samesite']
             );
         }
 
@@ -2407,6 +2409,7 @@ class Response implements ResponseInterface
             'secure' => $cookie->isSecure(),
             'httpOnly' => $cookie->isHttpOnly(),
             'expire' => $cookie->getExpiresTimestamp(),
+            'samesite' => $cookie->getSameSite(),
         ];
     }
 

+ 47 - 18
src/Http/ResponseEmitter.php

@@ -202,14 +202,15 @@ class ResponseEmitter implements EmitterInterface
     {
         foreach ($cookies as $cookie) {
             if (is_array($cookie)) {
-                setcookie(
+                $options = $cookie;
+                $options['httponly'] = $options['httpOnly'];
+                $options['expires'] = $options['expire'];
+                unset($options['name'], $options['value'], $options['httpOnly'], $options['expire']);
+
+                $this->setcookie(
                     $cookie['name'],
                     $cookie['value'],
-                    $cookie['expire'],
-                    $cookie['path'],
-                    $cookie['domain'],
-                    $cookie['secure'],
-                    $cookie['httpOnly']
+                    $options
                 );
                 continue;
             }
@@ -222,40 +223,68 @@ class ResponseEmitter implements EmitterInterface
             }
 
             list($name, $value) = explode('=', array_shift($parts), 2);
+            $name = urldecode($name);
+            $value = urldecode($value);
             $data = [
-                'name' => urldecode($name),
-                'value' => urldecode($value),
                 'expires' => 0,
                 'path' => '',
                 'domain' => '',
                 'secure' => false,
                 'httponly' => false,
+                'samesite' => null,
             ];
 
             foreach ($parts as $part) {
                 if (strpos($part, '=') !== false) {
-                    list($key, $value) = explode('=', $part);
+                    list($key, $val) = explode('=', $part);
                 } else {
                     $key = $part;
-                    $value = true;
+                    $val = true;
                 }
 
                 $key = strtolower($key);
-                $data[$key] = $value;
+                $data[$key] = $val;
             }
             if (is_string($data['expires'])) {
                 $data['expires'] = strtotime($data['expires']);
             }
+            $this->setcookie($name, $value, $data);
+        }
+    }
+
+    /**
+     * Set cookies uses setcookie()
+     *
+     * @param string $name Cookie name.
+     * @param string $value Cookie value.
+     * @param array $options Cookie options.
+     * @return void
+     */
+    protected function setcookie($name, $value, array $options)
+    {
+        if (PHP_VERSION_ID >= 70300) {
             setcookie(
-                $data['name'],
-                $data['value'],
-                $data['expires'],
-                $data['path'],
-                $data['domain'],
-                $data['secure'],
-                $data['httponly']
+                $name,
+                $value,
+                $options
             );
+
+            return;
         }
+
+        if (!empty($options['samesite'])) {
+            $options['path'] .= '; SameSite=' . $options['samesite'];
+        }
+
+        setcookie(
+            $name,
+            $value,
+            $options['expires'],
+            $options['path'],
+            $options['domain'],
+            $options['secure'],
+            $options['httponly']
+        );
     }
 
     /**

+ 34 - 0
tests/TestCase/Http/ResponseEmitterTest.php

@@ -112,6 +112,7 @@ class ResponseEmitterTest extends TestCase
     {
         $response = (new Response())
             ->withCookie(new Cookie('simple', 'val', null, '/', '', true))
+            ->withCookie(new Cookie('samesite', 'val', null, '/', '', true, false, 'Lax'))
             ->withAddedHeader('Set-Cookie', 'google=not=nice;Path=/accounts; HttpOnly')
             ->withHeader('Content-Type', 'text/plain');
         $response->getBody()->write('ok');
@@ -137,6 +138,16 @@ class ResponseEmitterTest extends TestCase
                 'httponly' => false,
             ],
             [
+                'name' => 'samesite',
+                'value' => 'val',
+                'path' => '/',
+                'expire' => 0,
+                'domain' => '',
+                'secure' => true,
+                'httponly' => false,
+                'samesite' => 'Lax',
+            ],
+            [
                 'name' => 'google',
                 'value' => 'not=nice',
                 'path' => '/accounts',
@@ -146,6 +157,12 @@ class ResponseEmitterTest extends TestCase
                 'httponly' => true,
             ],
         ];
+
+        if (PHP_VERSION_ID < 70300) {
+            $expected[1]['path'] = '/; SameSite=Lax';
+            unset($expected[1]['samesite']);
+        }
+
         $this->assertEquals($expected, $GLOBALS['mockedCookies']);
     }
 
@@ -162,6 +179,7 @@ class ResponseEmitterTest extends TestCase
             ->withAddedHeader('Set-Cookie', 'google=not=nice;Path=/accounts; HttpOnly')
             ->withAddedHeader('Set-Cookie', 'a=b;  Expires=Wed, 13 Jan 2021 22:23:01 GMT; Domain=www.example.com;')
             ->withAddedHeader('Set-Cookie', 'list%5B%5D=a%20b%20c')
+            ->withAddedHeader('Set-Cookie', "samesite=val;Path=/;SameSite=None")
             ->withHeader('Content-Type', 'text/plain');
         $response->getBody()->write('ok');
 
@@ -221,7 +239,23 @@ class ResponseEmitterTest extends TestCase
                 'secure' => false,
                 'httponly' => false,
             ],
+            [
+                'name' => 'samesite',
+                'value' => 'val',
+                'path' => '/',
+                'expire' => 0,
+                'domain' => '',
+                'secure' => false,
+                'httponly' => false,
+                'samesite' => 'None',
+            ],
         ];
+
+        if (PHP_VERSION_ID < 70300) {
+            $expected[5]['path'] = '/; SameSite=None';
+            unset($expected[5]['samesite']);
+        }
+
         $this->assertEquals($expected, $GLOBALS['mockedCookies']);
     }
 

+ 13 - 1
tests/TestCase/Http/ResponseTest.php

@@ -1586,6 +1586,7 @@ class ResponseTest extends TestCase
                 'domain' => '',
                 'secure' => false,
                 'httpOnly' => false,
+                'samesite' => null,
             ];
             $result = $response->cookie('CakeTestCookie[Testing]');
             $this->assertEquals($expected, $result);
@@ -1607,6 +1608,7 @@ class ResponseTest extends TestCase
                     'domain' => '',
                     'secure' => false,
                     'httpOnly' => false,
+                    'samesite' => null,
                 ],
                 'CakeTestCookie[Testing2]' => [
                     'name' => 'CakeTestCookie[Testing2]',
@@ -1616,6 +1618,7 @@ class ResponseTest extends TestCase
                     'domain' => '',
                     'secure' => true,
                     'httpOnly' => false,
+                    'samesite' => null,
                 ],
             ];
 
@@ -1634,6 +1637,7 @@ class ResponseTest extends TestCase
                     'domain' => '',
                     'secure' => false,
                     'httpOnly' => false,
+                    'samesite' => null,
                 ],
                 'CakeTestCookie[Testing2]' => [
                     'name' => 'CakeTestCookie[Testing2]',
@@ -1643,6 +1647,7 @@ class ResponseTest extends TestCase
                     'domain' => '',
                     'secure' => true,
                     'httpOnly' => false,
+                    'samesite' => null,
                 ],
             ];
 
@@ -1669,7 +1674,9 @@ class ResponseTest extends TestCase
             'path' => '/',
             'domain' => '',
             'secure' => false,
-            'httpOnly' => false];
+            'httpOnly' => false,
+            'samesite' => null,
+        ];
         $result = $new->getCookie('testing');
         $this->assertEquals($expected, $result);
     }
@@ -1727,6 +1734,7 @@ class ResponseTest extends TestCase
             'domain' => '',
             'secure' => true,
             'httpOnly' => false,
+            'samesite' => null,
         ];
 
         // Match the date time formatting to Response::convertCookieToArray
@@ -1776,6 +1784,7 @@ class ResponseTest extends TestCase
             'secure' => true,
             'httpOnly' => true,
             'expire' => new \DateTimeImmutable('+14 days'),
+            'samesite' => null,
         ];
 
         $cookie = new Cookie(
@@ -1833,6 +1842,7 @@ class ResponseTest extends TestCase
                 'domain' => '',
                 'secure' => false,
                 'httpOnly' => false,
+                'samesite' => null,
             ],
             'test2' => [
                 'name' => 'test2',
@@ -1842,6 +1852,7 @@ class ResponseTest extends TestCase
                 'domain' => '',
                 'secure' => true,
                 'httpOnly' => false,
+                'samesite' => null,
             ],
         ];
         $this->assertEquals($expected, $new->getCookies());
@@ -1869,6 +1880,7 @@ class ResponseTest extends TestCase
                 'domain' => '',
                 'secure' => false,
                 'httpOnly' => true,
+                'samesite' => null,
             ],
         ];
         $this->assertEquals($expected, $new->getCookies());

+ 2 - 0
tests/TestCase/Http/ResponseTransformerTest.php

@@ -181,6 +181,7 @@ class ResponseTransformerTest extends TestCase
             'expire' => 0,
             'secure' => false,
             'httpOnly' => false,
+            'samesite' => null,
         ];
         $this->assertEquals($expected, $result->cookie('remember_me'));
 
@@ -192,6 +193,7 @@ class ResponseTransformerTest extends TestCase
             'expire' => 1610541040,
             'secure' => true,
             'httpOnly' => true,
+            'samesite' => null,
         ];
         $this->assertEquals($expected, $result->cookie('forever'));
     }

+ 12 - 1
tests/TestCase/Http/server_mocks.php

@@ -14,8 +14,19 @@ function header($header)
     $GLOBALS['mockedHeaders'][] = $header;
 }
 
-function setcookie($name, $value, $expire, $path, $domain, $secure = false, $httponly = false)
+function setcookie($name, $value, $expire, $path = '', $domain = '', $secure = false, $httponly = false)
 {
+    if (is_array($expire)) {
+        if (array_key_exists('expires', $expire)) {
+            $expire['expire'] = $expire['expires'];
+            unset($expire['expires']);
+        }
+
+        $GLOBALS['mockedCookies'][] = compact('name', 'value') + $expire;
+
+        return;
+    }
+
     $GLOBALS['mockedCookies'][] = compact(
         'name',
         'value',