1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus framework.
7: *
8: * (c) John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Cookie;
15:
16: /**
17: * Validates a cookie against the RFC 6265 specification.
18: *
19: * @see https://datatracker.ietf.org/doc/html/rfc6265
20: * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie
21: */
22: final class CookieValidator
23: {
24: public const string HOST_PREFIX = '__Host-';
25: public const string SECURE_PREFIX = '__Secure-';
26:
27: /**
28: * A cookie name can be any US-ASCII characters, except control characters,
29: * spaces, tabs, or separator characters.
30: *
31: * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
32: * @see https://tools.ietf.org/html/rfc2616#section-2.2
33: */
34: public const string RESERVED_CHARS_LIST = "()<>@,;:\\\"/[]?={} \t\r\n\f\v";
35:
36: /**
37: * RFC 6265 allowed values for the "SameSite" attribute.
38: *
39: * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
40: */
41: public const array ALLOWED_SAMESITE_VALUES = [
42: CookieInterface::SAMESITE_NONE,
43: CookieInterface::SAMESITE_LAX,
44: CookieInterface::SAMESITE_STRICT,
45: ];
46:
47: /**
48: * @throws \InvalidArgumentException
49: *
50: * @phpstan-assert non-empty-string $name
51: */
52: public static function validateName(string $name, bool $raw): void
53: {
54: if ($raw && strpbrk($name, self::RESERVED_CHARS_LIST) !== false) {
55: throw new \InvalidArgumentException(\sprintf('Cookie name "%s" contains invalid characters.', $name));
56: }
57:
58: if ('' === $name) {
59: throw new \InvalidArgumentException('Cookie name cannot be empty.');
60: }
61: }
62:
63: /**
64: * @throws \InvalidArgumentException
65: */
66: public static function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void
67: {
68: if (str_starts_with($prefix, self::SECURE_PREFIX) && ! $secure) {
69: throw new \InvalidArgumentException(\sprintf(
70: 'Cookie prefix "%s" must be set with a "Secure" attribute from a secure page.',
71: $prefix,
72: ));
73: }
74:
75: if (str_starts_with($prefix, self::HOST_PREFIX) && (! $secure || '' !== $domain || '/' !== $path)) {
76: throw new \InvalidArgumentException(\sprintf(
77: 'Cookie prefix "%s" must be set with a "Secure" attribute from a secure page, with an empty domain, and path of "/".',
78: $prefix,
79: ));
80: }
81: }
82:
83: /**
84: * @throws \InvalidArgumentException
85: */
86: public static function validatePartitioned(bool $partitioned, bool $secure, string $sameSite, string $prefix): void
87: {
88: if ($partitioned && ! $secure) {
89: throw new \InvalidArgumentException(
90: 'Partitioned cookies must be set with a "Secure" attribute.',
91: );
92: }
93:
94: if ($partitioned && CookieInterface::SAMESITE_NONE !== $sameSite) {
95: throw new \InvalidArgumentException(\sprintf(
96: 'Partitioned cookies must have a SameSite value of "%s".',
97: CookieInterface::SAMESITE_NONE,
98: ));
99: }
100:
101: if ($partitioned && ! str_starts_with($prefix, self::HOST_PREFIX)) {
102: throw new \InvalidArgumentException(\sprintf(
103: 'Partitioned cookies must have a prefix of "%s".',
104: self::HOST_PREFIX,
105: ));
106: }
107: }
108:
109: /**
110: * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
111: *
112: * @throws \InvalidArgumentException
113: *
114: * @phpstan-assert 'None'|'Lax'|'Strict' $sameSite
115: */
116: public static function validateSameSite(string $sameSite, bool $secure): void
117: {
118: if (! \in_array($sameSite, self::ALLOWED_SAMESITE_VALUES, true)) {
119: throw new \InvalidArgumentException(\sprintf(
120: 'Invalid SameSite value "%s". Allowed values are: "%s".',
121: $sameSite,
122: implode('", "', self::ALLOWED_SAMESITE_VALUES),
123: ));
124: }
125:
126: if (CookieInterface::SAMESITE_NONE === $sameSite && ! $secure) {
127: throw new \InvalidArgumentException('SameSite=None must be set with a "Secure" attribute.');
128: }
129: }
130: }
131: