Skip to content

Commit d47c2dc

Browse files
authored
fix(Cookie): validate cookie path and domain attributes (#10527)
1 parent 2155a89 commit d47c2dc

6 files changed

Lines changed: 326 additions & 8 deletions

File tree

system/Cookie/Cookie.php

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,14 @@ class Cookie implements ArrayAccess, CloneableCookieInterface
127127
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
128128
* @see https://tools.ietf.org/html/rfc2616#section-2.2
129129
*/
130-
private static string $reservedCharsList = "=,; \t\r\n\v\f()<>@:\\\"/[]?{}";
130+
private static string $reservedCharsList = "=,; \t\r\n\v\f\0()<>@:\\\"/[]?{}";
131+
132+
/**
133+
* Prohibited characters in cookie prefix and name per PHP setcookie() constraints.
134+
*
135+
* @see https://www.php.net/manual/en/function.setcookie.php
136+
*/
137+
private static string $reservedPrefixCharsList = "=,; \t\r\n\v\f\0";
131138

132139
/**
133140
* @see https://www.php.net/manual/en/function.setrawcookie.php
@@ -232,11 +239,11 @@ public static function fromHeaderString(string $cookie, bool $raw = false)
232239
* @param string $name The cookie's name
233240
* @param string $value The cookie's value
234241
* @param array{
235-
* prefix?: string,
242+
* prefix?: string|null,
236243
* max-age?: int|numeric-string,
237244
* expires?: DateTimeInterface|int|string,
238-
* path?: string,
239-
* domain?: string,
245+
* path?: string|null,
246+
* domain?: string|null,
240247
* secure?: bool,
241248
* httponly?: bool,
242249
* samesite?: string,
@@ -258,9 +265,9 @@ final public function __construct(string $name, string $value = '', array $optio
258265
}
259266

260267
// to preserve backward compatibility with array-based cookies in previous CI versions
261-
$prefix = ($options['prefix'] === '') ? self::$defaults['prefix'] : $options['prefix'];
262-
$path = ($options['path'] === '') ? self::$defaults['path'] : $options['path'];
263-
$domain = ($options['domain'] === '') ? self::$defaults['domain'] : $options['domain'];
268+
$prefix = in_array($options['prefix'], [null, ''], true) ? self::$defaults['prefix'] : $options['prefix'];
269+
$path = in_array($options['path'], [null, '', '0'], true) ? self::$defaults['path'] : $options['path'];
270+
$domain = in_array($options['domain'], [null, ''], true) ? self::$defaults['domain'] : $options['domain'];
264271

265272
// empty string SameSite should use the default for browsers
266273
$samesite = ($options['samesite'] === '') ? self::$defaults['samesite'] : $options['samesite'];
@@ -271,6 +278,8 @@ final public function __construct(string $name, string $value = '', array $optio
271278

272279
$this->validateName($name, $raw);
273280
$this->validateValue($value, $raw);
281+
$this->validatePath($path);
282+
$this->validateDomain($domain);
274283
$this->validatePrefix($prefix, $secure, $path, $domain);
275284
$this->validateSameSite($samesite, $secure);
276285

@@ -515,6 +524,7 @@ public function withExpired()
515524
public function withPath(?string $path)
516525
{
517526
$path = in_array($path, [null, '', '0'], true) ? self::$defaults['path'] : $path;
527+
$this->validatePath($path);
518528
$this->validatePrefix($this->prefix, $this->secure, $path, $this->domain);
519529

520530
$cookie = clone $this;
@@ -530,6 +540,7 @@ public function withPath(?string $path)
530540
public function withDomain(?string $domain)
531541
{
532542
$domain ??= self::$defaults['domain'];
543+
$this->validateDomain($domain);
533544
$this->validatePrefix($this->prefix, $this->secure, $this->path, $domain);
534545

535546
$cookie = clone $this;
@@ -791,12 +802,41 @@ protected function validateValue(string $value, bool $raw): void
791802
}
792803

793804
/**
794-
* Validates the special prefixes if some attribute requirements are met.
805+
* Validates the cookie path per PHP setcookie() constraints.
806+
*
807+
* @throws CookieException
808+
*/
809+
protected function validatePath(string $path): void
810+
{
811+
if (strpbrk($path, self::$reservedValueCharsList) !== false) {
812+
throw CookieException::forInvalidCookiePath();
813+
}
814+
}
815+
816+
/**
817+
* Validates the cookie domain per PHP setcookie() constraints.
818+
*
819+
* @throws CookieException
820+
*/
821+
protected function validateDomain(string $domain): void
822+
{
823+
if ($domain !== '' && strpbrk($domain, self::$reservedValueCharsList) !== false) {
824+
throw CookieException::forInvalidCookieDomain();
825+
}
826+
}
827+
828+
/**
829+
* Validates the special prefixes if some attribute requirements are met,
830+
* and ensures the prefix contains no PHP-prohibited characters.
795831
*
796832
* @throws CookieException
797833
*/
798834
protected function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void
799835
{
836+
if (strpbrk($prefix, self::$reservedPrefixCharsList) !== false) {
837+
throw CookieException::forInvalidCookieName($prefix);
838+
}
839+
800840
if (str_starts_with($prefix, '__Secure-') && ! $secure) {
801841
throw CookieException::forInvalidSecurePrefix();
802842
}

system/Cookie/Exceptions/CookieException.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,26 @@ public static function forInvalidCookieValue()
7070
return new static(lang('Cookie.invalidCookieValue'));
7171
}
7272

73+
/**
74+
* Thrown when the cookie path contains invalid characters.
75+
*
76+
* @return static
77+
*/
78+
public static function forInvalidCookiePath()
79+
{
80+
return new static(lang('Cookie.invalidCookiePath'));
81+
}
82+
83+
/**
84+
* Thrown when the cookie domain contains invalid characters.
85+
*
86+
* @return static
87+
*/
88+
public static function forInvalidCookieDomain()
89+
{
90+
return new static(lang('Cookie.invalidCookieDomain'));
91+
}
92+
7393
/**
7494
* Thrown when using the `__Secure-` prefix but the `Secure` attribute
7595
* is not set to true.

system/Language/en/Cookie.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
'invalidExpiresValue' => 'The cookie expiration time is not valid.',
1818
'invalidCookieName' => 'The cookie name "{0}" contains invalid characters.',
1919
'invalidCookieValue' => 'The cookie value contains invalid characters.',
20+
'invalidCookiePath' => 'The cookie path contains invalid characters.',
21+
'invalidCookieDomain' => 'The cookie domain contains invalid characters.',
2022
'emptyCookieName' => 'The cookie name cannot be empty.',
2123
'invalidSecurePrefix' => 'Using the "__Secure-" prefix requires setting the "Secure" attribute.',
2224
'invalidHostPrefix' => 'Using the "__Host-" prefix must be set with the "Secure" flag, must not have a "Domain" attribute, and the "Path" is set to "/".',

tests/system/Cookie/CookieTest.php

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,4 +443,239 @@ public function testNonRawCookieSafelyEncodesCRLF(): void
443443
$this->assertStringNotContainsString("\r", $result);
444444
$this->assertStringNotContainsString("\n", $result);
445445
}
446+
447+
#[DataProvider('provideValidationOfCookiePath')]
448+
public function testValidationOfCookiePath(string $path): void
449+
{
450+
$this->expectException(CookieException::class);
451+
$this->expectExceptionMessage(lang('Cookie.invalidCookiePath'));
452+
new Cookie('test', 'value', ['path' => $path]);
453+
}
454+
455+
#[DataProvider('provideValidationOfCookiePath')]
456+
public function testValidationOfCookiePathInWithPath(string $path): void
457+
{
458+
$this->expectException(CookieException::class);
459+
$this->expectExceptionMessage(lang('Cookie.invalidCookiePath'));
460+
$cookie = new Cookie('test', 'value');
461+
$cookie->withPath($path);
462+
}
463+
464+
/**
465+
* @return iterable<string, array{string}>
466+
*/
467+
public static function provideValidationOfCookiePath(): iterable
468+
{
469+
yield 'comma' => ['/path,comma'];
470+
471+
yield 'semicolon' => ['/path;semicolon'];
472+
473+
yield 'space' => ['/path with space'];
474+
475+
yield 'tab' => ["/path\twith_tab"];
476+
477+
yield 'carriage return' => ["/path\rcarriage"];
478+
479+
yield 'newline' => ["/path\nnewline"];
480+
481+
yield 'vertical tab' => ["/path\vvertical_tab"];
482+
483+
yield 'form feed' => ["/path\fform_feed"];
484+
485+
yield 'null byte' => ["/path\0null_byte"];
486+
487+
yield 'CRLF' => ["/path\r\nwith_crlf"];
488+
}
489+
490+
#[DataProvider('provideFromHeaderStringValidationOfCookiePath')]
491+
public function testFromHeaderStringValidationOfCookiePath(string $path): void
492+
{
493+
$this->expectException(CookieException::class);
494+
$this->expectExceptionMessage(lang('Cookie.invalidCookiePath'));
495+
Cookie::fromHeaderString("test=value; Path={$path}");
496+
}
497+
498+
/**
499+
* @return iterable<string, array{string}>
500+
*/
501+
public static function provideFromHeaderStringValidationOfCookiePath(): iterable
502+
{
503+
foreach (self::provideValidationOfCookiePath() as $name => $case) {
504+
if ($name === 'semicolon') {
505+
continue;
506+
}
507+
508+
yield $name => $case;
509+
}
510+
}
511+
512+
#[DataProvider('provideValidationOfCookieDomain')]
513+
public function testValidationOfCookieDomain(string $domain): void
514+
{
515+
$this->expectException(CookieException::class);
516+
$this->expectExceptionMessage(lang('Cookie.invalidCookieDomain'));
517+
new Cookie('test', 'value', ['domain' => $domain]);
518+
}
519+
520+
#[DataProvider('provideValidationOfCookieDomain')]
521+
public function testValidationOfCookieDomainInWithDomain(string $domain): void
522+
{
523+
$this->expectException(CookieException::class);
524+
$this->expectExceptionMessage(lang('Cookie.invalidCookieDomain'));
525+
$cookie = new Cookie('test', 'value');
526+
$cookie->withDomain($domain);
527+
}
528+
529+
/**
530+
* @return iterable<string, array{string}>
531+
*/
532+
public static function provideValidationOfCookieDomain(): iterable
533+
{
534+
yield 'comma' => ['domain,comma.com'];
535+
536+
yield 'semicolon' => ['domain;semicolon.com'];
537+
538+
yield 'space' => ['domain with space.com'];
539+
540+
yield 'tab' => ["domain\twith_tab.com"];
541+
542+
yield 'carriage return' => ["domain\rcarriage.com"];
543+
544+
yield 'newline' => ["domain\nnewline.com"];
545+
546+
yield 'vertical tab' => ["domain\vvertical_tab.com"];
547+
548+
yield 'form feed' => ["domain\fform_feed.com"];
549+
550+
yield 'null byte' => ["domain\0null_byte.com"];
551+
552+
yield 'CRLF' => ["domain\r\nwith_crlf.com"];
553+
}
554+
555+
#[DataProvider('provideFromHeaderStringValidationOfCookieDomain')]
556+
public function testFromHeaderStringValidationOfCookieDomain(string $domain): void
557+
{
558+
$this->expectException(CookieException::class);
559+
$this->expectExceptionMessage(lang('Cookie.invalidCookieDomain'));
560+
Cookie::fromHeaderString("test=value; Domain={$domain}");
561+
}
562+
563+
/**
564+
* @return iterable<string, array{string}>
565+
*/
566+
public static function provideFromHeaderStringValidationOfCookieDomain(): iterable
567+
{
568+
foreach (self::provideValidationOfCookieDomain() as $name => $case) {
569+
if ($name === 'semicolon') {
570+
continue;
571+
}
572+
573+
yield $name => $case;
574+
}
575+
}
576+
577+
public function testNullPathAndDomainDefaultProperly(): void
578+
{
579+
$cookie = new Cookie('test', 'val', ['path' => null, 'domain' => null, 'prefix' => null]);
580+
581+
$this->assertSame('/', $cookie->getPath());
582+
$this->assertSame('', $cookie->getDomain());
583+
$this->assertSame('', $cookie->getPrefix());
584+
585+
$cookie2 = $cookie->withPath(null)->withDomain(null)->withPrefix('');
586+
$this->assertSame('/', $cookie2->getPath());
587+
$this->assertSame('', $cookie2->getDomain());
588+
$this->assertSame('', $cookie2->getPrefix());
589+
}
590+
591+
public function testValidCookiePathAndDomain(): void
592+
{
593+
$cookie = new Cookie('test', 'val', ['path' => '/sub/dir/', 'domain' => 'example.com']);
594+
$this->assertSame('/sub/dir/', $cookie->getPath());
595+
$this->assertSame('example.com', $cookie->getDomain());
596+
597+
$cookie2 = $cookie->withPath('/another/path')->withDomain('.example.com');
598+
$this->assertSame('/another/path', $cookie2->getPath());
599+
$this->assertSame('.example.com', $cookie2->getDomain());
600+
601+
$cookie3 = new Cookie('test', 'val', ['path' => '/', 'domain' => '']);
602+
$this->assertSame('/', $cookie3->getPath());
603+
$this->assertSame('', $cookie3->getDomain());
604+
}
605+
606+
#[DataProvider('provideValidationOfCookiePrefix')]
607+
public function testValidationOfCookiePrefix(string $prefix): void
608+
{
609+
$this->expectException(CookieException::class);
610+
$this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix]));
611+
new Cookie('test', 'val', ['prefix' => $prefix]);
612+
}
613+
614+
#[DataProvider('provideValidationOfCookiePrefix')]
615+
public function testValidationOfCookiePrefixInWithPrefix(string $prefix): void
616+
{
617+
$this->expectException(CookieException::class);
618+
$this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix]));
619+
$cookie = new Cookie('test', 'val');
620+
$cookie->withPrefix($prefix);
621+
}
622+
623+
#[DataProvider('provideValidationOfCookiePrefix')]
624+
public function testValidationOfRawCookiePrefix(string $prefix): void
625+
{
626+
$this->expectException(CookieException::class);
627+
$this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix]));
628+
new Cookie('test', 'val', ['prefix' => $prefix, 'raw' => true]);
629+
}
630+
631+
#[DataProvider('provideValidationOfCookiePrefix')]
632+
public function testValidationOfRawCookiePrefixInWithPrefix(string $prefix): void
633+
{
634+
$this->expectException(CookieException::class);
635+
$this->expectExceptionMessage(lang('Cookie.invalidCookieName', [$prefix]));
636+
$cookie = new Cookie('test', 'val', ['raw' => true]);
637+
$cookie->withPrefix($prefix);
638+
}
639+
640+
/**
641+
* @return iterable<string, array{string}>
642+
*/
643+
public static function provideValidationOfCookiePrefix(): iterable
644+
{
645+
yield 'equals' => ['prefix='];
646+
647+
yield 'comma' => ['prefix,'];
648+
649+
yield 'semicolon' => ['prefix;'];
650+
651+
yield 'space' => ['prefix '];
652+
653+
yield 'tab' => ["prefix\t"];
654+
655+
yield 'carriage return' => ["prefix\r"];
656+
657+
yield 'newline' => ["prefix\n"];
658+
659+
yield 'vertical tab' => ["prefix\v"];
660+
661+
yield 'form feed' => ["prefix\f"];
662+
663+
yield 'null byte' => ["prefix\0"];
664+
665+
yield 'CRLF' => ["prefix\r\n"];
666+
}
667+
668+
public function testValidCookiePrefixAllowedSeparators(): void
669+
{
670+
$cookie = new Cookie('test', 'val', ['prefix' => 'ci:session/']);
671+
$this->assertSame('ci:session/', $cookie->getPrefix());
672+
$this->assertSame('ci:session/test', $cookie->getPrefixedName());
673+
674+
$cookie2 = $cookie->withPrefix('my-app:v1/');
675+
$this->assertSame('my-app:v1/', $cookie2->getPrefix());
676+
$this->assertSame('my-app:v1/test', $cookie2->getPrefixedName());
677+
678+
$cookie3 = new Cookie('test', 'val', ['prefix' => 'ci:session/', 'raw' => false]);
679+
$this->assertSame('ci:session/test=val; Path=/; HttpOnly; SameSite=Lax', $cookie3->toHeaderString());
680+
}
446681
}

0 commit comments

Comments
 (0)