Skip to content

Commit

Permalink
Add support for SameSite attribute (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
kelunik committed Sep 12, 2019
1 parent b7ea3dd commit 12e1341
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 35 deletions.
93 changes: 71 additions & 22 deletions src/Cookie/CookieAttributes.php
Expand Up @@ -9,23 +9,9 @@
*/
final class CookieAttributes
{
/** @var string */
private $path = '';

/** @var string */
private $domain = '';

/** @var int|null */
private $maxAge;

/** @var \DateTimeImmutable */
private $expiry;

/** @var bool */
private $secure = false;

/** @var bool */
private $httpOnly = true;
public const SAMESITE_NONE = 'None';
public const SAMESITE_LAX = 'Lax';
public const SAMESITE_STRICT = 'Strict';

/**
* @return CookieAttributes No cookie attributes.
Expand All @@ -50,6 +36,21 @@ public static function default(): self
return new self;
}

/** @var string */
private $path = '';
/** @var string */
private $domain = '';
/** @var int|null */
private $maxAge;
/** @var \DateTimeImmutable */
private $expiry;
/** @var bool */
private $secure = false;
/** @var bool */
private $httpOnly = true;
/** @var string|null */
private $sameSite;

private function __construct()
{
// only allow creation via named constructors
Expand All @@ -58,7 +59,8 @@ private function __construct()
/**
* @param string $path Cookie path.
*
* @return self Cloned instance with the specified operation applied. Cloned instance with the specified operation applied.
* @return self Cloned instance with the specified operation applied. Cloned instance with the specified operation
* applied.
*
* @link https://tools.ietf.org/html/rfc6265#section-5.2.4
*/
Expand All @@ -85,6 +87,39 @@ public function withDomain(string $domain): self
return $new;
}

/**
* @param string $sameSite Cookie SameSite attribute value.
*
* @return self Cloned instance with the specified operation applied.
*
* @link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-5.3.7
*/
public function withSameSite(string $sameSite): self
{
$normalizedValue = \ucfirst(\strtolower($sameSite));
if (!\in_array($normalizedValue, [self::SAMESITE_NONE, self::SAMESITE_LAX, self::SAMESITE_STRICT], true)) {
throw new \Error("Invalid SameSite attribute: " . $sameSite);
}

$new = clone $this;
$new->sameSite = $normalizedValue;

return $new;
}

/**
* @return self Cloned instance with the specified operation applied.
*
* @link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-5.3.7
*/
public function withoutSameSite(): self
{
$new = clone $this;
$new->sameSite = null;

return $new;
}

/**
* Applies the given maximum age to the cookie.
*
Expand Down Expand Up @@ -246,13 +281,23 @@ public function getDomain(): string
return $this->domain;
}

/**
* @return string Cookie domain.
*
* @link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-5.3.7
*/
public function getSameSite(): ?string
{
return $this->sameSite;
}

/**
* @return int|null Cookie maximum age in seconds or `null` if no value is set.
*
* @link https://tools.ietf.org/html/rfc6265#section-5.2.2
*/
public function getMaxAge()
{ /* : ?int */
public function getMaxAge(): ?int
{
return $this->maxAge;
}

Expand All @@ -261,8 +306,8 @@ public function getMaxAge()
*
* @link https://tools.ietf.org/html/rfc6265#section-5.2.2
*/
public function getExpiry()
{ /* : ?\DateTimeImmutable */
public function getExpiry(): ?\DateTimeImmutable
{
return $this->expiry;
}

Expand Down Expand Up @@ -317,6 +362,10 @@ public function __toString(): string
$string .= '; HttpOnly';
}

if ($this->sameSite !== null) {
$string .= '; SameSite=' . $this->sameSite;
}

return $string;
}
}
50 changes: 39 additions & 11 deletions src/Cookie/ResponseCookie.php
Expand Up @@ -22,18 +22,15 @@ final class ResponseCookie
'D M d H:i:s Y T',
];

/** @var string[] */
private $unknownAttributes = [];

/**
* Parses a cookie from a 'set-cookie' header.
*
* @param string $string Valid 'set-cookie' header line.
*
* @return self|null Returns a `ResponseCookie` instance on success and `null` on failure.
*/
public static function fromHeader(string $string)
{ /* : ?self */
public static function fromHeader(string $string): ?self
{
$parts = \array_map("trim", \explode(";", $string));
$nameValue = \explode("=", \array_shift($parts), 2);

Expand Down Expand Up @@ -103,6 +100,20 @@ public static function fromHeader(string $string)
$meta = $meta->withDomain($pieces[1]);
break;

case 'samesite':
$normalizedValue = \ucfirst(\strtolower($pieces[1]));
if (!\in_array($normalizedValue, [
CookieAttributes::SAMESITE_NONE,
CookieAttributes::SAMESITE_LAX,
CookieAttributes::SAMESITE_STRICT,
], true)) {
$unknownAttributes[] = $part;
} else {
$meta = $meta->withSameSite($normalizedValue);
}

break;

default:
$unknownAttributes[] = $part;
break;
Expand All @@ -125,8 +136,8 @@ public static function fromHeader(string $string)
*
* @return \DateTimeImmutable|null Parsed date.
*/
private static function parseDate(string $date)
{ /* ?\DateTimeImmutable */
private static function parseDate(string $date): ?\DateTimeImmutable
{
foreach (self::$dateFormats as $dateFormat) {
if ($parsedDate = \DateTimeImmutable::createFromFormat($dateFormat, $date, new \DateTimeZone('GMT'))) {
return $parsedDate;
Expand All @@ -136,6 +147,8 @@ private static function parseDate(string $date)
return null;
}

/** @var string[] */
private $unknownAttributes = [];
/** @var string */
private $name;
/** @var string */
Expand Down Expand Up @@ -213,8 +226,8 @@ public function withValue(string $value): self
*
* @link https://tools.ietf.org/html/rfc6265#section-5.2.1
*/
public function getExpiry()
{ /* : ?\DateTimeImmutable */
public function getExpiry(): ?\DateTimeImmutable
{
return $this->attributes->getExpiry();
}

Expand All @@ -233,8 +246,8 @@ public function withoutExpiry(): self
*
* @link https://tools.ietf.org/html/rfc6265#section-5.2.2
*/
public function getMaxAge()
{ /* : ?int */
public function getMaxAge(): ?int
{
return $this->attributes->getMaxAge();
}

Expand Down Expand Up @@ -318,6 +331,21 @@ public function withoutHttpOnly(): self
return $this->withAttributes($this->attributes->withoutHttpOnly());
}

public function withSameSite(string $sameSite): self
{
return $this->withAttributes($this->attributes->withSameSite($sameSite));
}

public function withoutSameSite(): self
{
return $this->withAttributes($this->attributes->withoutSameSite());
}

public function getSameSite(): ?string
{
return $this->attributes->getSameSite();
}

/**
* @return CookieAttributes All cookie attributes.
*/
Expand Down
22 changes: 21 additions & 1 deletion test/Cookie/CookieAttributesTest.php
Expand Up @@ -17,6 +17,22 @@ public function testMaxAge()
$this->assertNull($attributes->getExpiry());
}

public function testSameSite()
{
$attributes = CookieAttributes::default()->withSameSite(CookieAttributes::SAMESITE_LAX);
$this->assertSame('Lax', $attributes->getSameSite());

$attributes = $attributes->withoutSameSite();
$this->assertNull($attributes->getSameSite());
}

public function testSameSite_invalidValue()
{
$this->expectException(\Error::class);

CookieAttributes::default()->withSameSite('fo');
}

public function testExpiry()
{
$expiry = new \DateTimeImmutable("now+10s");
Expand Down Expand Up @@ -58,6 +74,10 @@ public function testToString()
$this->assertSame('; Max-Age=10; HttpOnly', (string) $attributes->withMaxAge(10));
$this->assertSame('; Path=/; HttpOnly', (string) $attributes->withPath('/'));
$this->assertSame('; Domain=localhost; HttpOnly', (string) $attributes->withDomain('localhost'));
$this->assertSame('; Expires=' . \gmdate('D, j M Y G:i:s T', $expiry->getTimestamp()) . '; HttpOnly', (string) $attributes->withExpiry($expiry));
$this->assertSame(
'; Expires=' . \gmdate('D, j M Y G:i:s T', $expiry->getTimestamp()) . '; HttpOnly',
(string) $attributes->withExpiry($expiry)
);
$this->assertSame('; HttpOnly; SameSite=Strict', (string) $attributes->withSameSite('strict'));
}
}
31 changes: 30 additions & 1 deletion test/Cookie/ResponseCookieTest.php
Expand Up @@ -217,6 +217,16 @@ public function testModifyMaxAge()
$this->assertSame(12, $newCookie->getMaxAge());
}

public function testModifySameSite()
{
$cookie = new ResponseCookie("foobar", "what-is-this");
$newCookie = $cookie->withSameSite('Lax');

$this->assertNull($cookie->getSameSite());
$this->assertNull($newCookie->withoutSameSite()->getSameSite());
$this->assertSame('Lax', $newCookie->getSameSite());
}

public function testInvalidCookieName()
{
$this->expectException(InvalidCookieException::class);
Expand Down Expand Up @@ -249,13 +259,32 @@ public function testInvalidCookieValueModify()
$cookie->withValue('what is this');
}

public function testSameSiteInvalid()
{
$cookie = ResponseCookie::fromHeader('foo=bar; SameSite=lax');

$this->assertSame('foo', $cookie->getName());
$this->assertSame('bar', $cookie->getValue());
$this->assertSame('Lax', $cookie->getSameSite());
}

public function testPreservesUnknownAttributes()
{
$cookie = ResponseCookie::fromHeader('key=value; HttpOnly; SameSite=strict;Foobar');
$this->assertNotNull($cookie);
$this->assertSame('key', $cookie->getName());
$this->assertSame('value', $cookie->getValue());
$this->assertTrue($cookie->isHttpOnly());
$this->assertSame('key=value; HttpOnly; SameSite=strict; Foobar', (string) $cookie);
$this->assertSame('key=value; HttpOnly; SameSite=Strict; Foobar', (string) $cookie);
}

public function testPreservesUnknownAttributes_invalidSameSite()
{
$cookie = ResponseCookie::fromHeader('key=value; HttpOnly; SameSite=foo;Foobar; bla=x');
$this->assertNotNull($cookie);
$this->assertSame('key', $cookie->getName());
$this->assertSame('value', $cookie->getValue());
$this->assertTrue($cookie->isHttpOnly());
$this->assertSame('key=value; HttpOnly; SameSite=foo; Foobar; bla=x', (string) $cookie);
}
}

0 comments on commit 12e1341

Please sign in to comment.