WordPress.org

Make WordPress Core

Ticket #37000: samesite.diff

File samesite.diff, 20.7 KB (added by tomdxw, 2 years ago)
  • new file wp-includes/class-cookie.php

    commit 852b456196a0196c27b72feaa6f8aad4baa72e8c
    Author: Tom Adams <tom@dxw.com>
    Date:   Wed May 2 12:17:03 2018 -0400
    
        Add SameSite=Lax to auth cookies
    
    diff --git a/wp-includes/class-cookie.php b/wp-includes/class-cookie.php
    new file mode 100644
    index 0000000..686478f
    - +  
     1<?php
     2
     3/*
     4 * PHP-Cookie (https://github.com/delight-im/PHP-Cookie)
     5 * Copyright (c) delight.im (https://www.delight.im/)
     6 * Licensed under the MIT License (https://opensource.org/licenses/MIT)
     7 */
     8
     9// Unsupported by old versions of PHP
     10// namespace Delight\Cookie;
     11
     12/**
     13 * Modern cookie management for PHP
     14 *
     15 * Cookies are a mechanism for storing data in the client's web browser and identifying returning clients on subsequent visits
     16 *
     17 * All cookies that have successfully been set will automatically be included in the global `$_COOKIE` array with future requests
     18 *
     19 * You can set a new cookie using the static method `Cookie::setcookie(...)` which is compatible to PHP's built-in `setcookie(...)` function
     20 *
     21 * Alternatively, you can construct an instance of this class, set properties individually, and finally call `save()`
     22 *
     23 * Note that cookies must always be set before the HTTP headers are sent to the client, i.e. before the actual output starts
     24 */
     25final class Cookie {
     26
     27        /** @var string name prefix indicating that the cookie must be from a secure origin (i.e. HTTPS) and the 'secure' attribute must be set */
     28        const PREFIX_SECURE = '__Secure-';
     29        /** @var string name prefix indicating that the 'domain' attribute must *not* be set, the 'path' attribute must be '/' and the effects of {@see PREFIX_SECURE} apply as well */
     30        const PREFIX_HOST = '__Host-';
     31        const HEADER_PREFIX = 'Set-Cookie: ';
     32        const SAME_SITE_RESTRICTION_LAX = 'Lax';
     33        const SAME_SITE_RESTRICTION_STRICT = 'Strict';
     34
     35        /** @var string the name of the cookie which is also the key for future accesses via `$_COOKIE[...]` */
     36        private $name;
     37        /** @var mixed|null the value of the cookie that will be stored on the client's machine */
     38        private $value;
     39        /** @var int the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds` */
     40        private $expiryTime;
     41        /** @var string the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory */
     42        private $path;
     43        /** @var string|null the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains) */
     44        private $domain;
     45        /** @var bool indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages */
     46        private $httpOnly;
     47        /** @var bool indicates that the cookie should be sent back by the client over secure HTTPS connections only */
     48        private $secureOnly;
     49        /** @var string|null indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`) */
     50        private $sameSiteRestriction;
     51
     52        /**
     53         * Prepares a new cookie
     54         *
     55         * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     56         */
     57        public function __construct($name) {
     58                $this->name = $name;
     59                $this->value = null;
     60                $this->expiryTime = 0;
     61                $this->path = '/';
     62                $this->domain = null;
     63                $this->httpOnly = true;
     64                $this->secureOnly = false;
     65                $this->sameSiteRestriction = self::SAME_SITE_RESTRICTION_LAX;
     66        }
     67
     68        /**
     69         * Returns the name of the cookie
     70         *
     71         * @return string the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     72         */
     73        public function getName() {
     74                return $this->name;
     75        }
     76
     77        /**
     78         * Returns the value of the cookie
     79         *
     80         * @return mixed|null the value of the cookie that will be stored on the client's machine
     81         */
     82        public function getValue() {
     83                return $this->value;
     84        }
     85
     86        /**
     87         * Sets the value for the cookie
     88         *
     89         * @param mixed|null $value the value of the cookie that will be stored on the client's machine
     90         * @return static this instance for chaining
     91         */
     92        public function setValue($value) {
     93                $this->value = $value;
     94
     95                return $this;
     96        }
     97
     98        /**
     99         * Returns the expiry time of the cookie
     100         *
     101         * @return int the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     102         */
     103        public function getExpiryTime() {
     104                return $this->expiryTime;
     105        }
     106
     107        /**
     108         * Sets the expiry time for the cookie
     109         *
     110         * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     111         * @return static this instance for chaining
     112         */
     113        public function setExpiryTime($expiryTime) {
     114                $this->expiryTime = $expiryTime;
     115
     116                return $this;
     117        }
     118
     119        /**
     120         * Returns the maximum age of the cookie (i.e. the remaining lifetime)
     121         *
     122         * @return int the maximum age of the cookie in seconds
     123         */
     124        public function getMaxAge() {
     125                return $this->expiryTime - \time();
     126        }
     127
     128        /**
     129         * Sets the expiry time for the cookie based on the specified maximum age (i.e. the remaining lifetime)
     130         *
     131         * @param int $maxAge the maximum age for the cookie in seconds
     132         * @return static this instance for chaining
     133         */
     134        public function setMaxAge($maxAge) {
     135                $this->expiryTime = \time() + $maxAge;
     136
     137                return $this;
     138        }
     139
     140        /**
     141         * Returns the path of the cookie
     142         *
     143         * @return string the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     144         */
     145        public function getPath() {
     146                return $this->path;
     147        }
     148
     149        /**
     150         * Sets the path for the cookie
     151         *
     152         * @param string $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     153         * @return static this instance for chaining
     154         */
     155        public function setPath($path) {
     156                $this->path = $path;
     157
     158                return $this;
     159        }
     160
     161        /**
     162         * Returns the domain of the cookie
     163         *
     164         * @return string|null the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     165         */
     166        public function getDomain() {
     167                return $this->domain;
     168        }
     169
     170        /**
     171         * Sets the domain for the cookie
     172         *
     173         * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     174         * @return static this instance for chaining
     175         */
     176        public function setDomain($domain = null) {
     177                $this->domain = self::normalizeDomain($domain);
     178
     179                return $this;
     180        }
     181
     182        /**
     183         * Returns whether the cookie should be accessible through HTTP only
     184         *
     185         * @return bool whether the cookie should be accessible through the HTTP protocol only and not through scripting languages
     186         */
     187        public function isHttpOnly() {
     188                return $this->httpOnly;
     189        }
     190
     191        /**
     192         * Sets whether the cookie should be accessible through HTTP only
     193         *
     194         * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages
     195         * @return static this instance for chaining
     196         */
     197        public function setHttpOnly($httpOnly) {
     198                $this->httpOnly = $httpOnly;
     199
     200                return $this;
     201        }
     202
     203        /**
     204         * Returns whether the cookie should be sent over HTTPS only
     205         *
     206         * @return bool whether the cookie should be sent back by the client over secure HTTPS connections only
     207         */
     208        public function isSecureOnly() {
     209                return $this->secureOnly;
     210        }
     211
     212        /**
     213         * Sets whether the cookie should be sent over HTTPS only
     214         *
     215         * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only
     216         * @return static this instance for chaining
     217         */
     218        public function setSecureOnly($secureOnly) {
     219                $this->secureOnly = $secureOnly;
     220
     221                return $this;
     222        }
     223
     224        /**
     225         * Returns the same-site restriction of the cookie
     226         *
     227         * @return string|null whether the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`)
     228         */
     229        public function getSameSiteRestriction() {
     230                return $this->sameSiteRestriction;
     231        }
     232
     233        /**
     234         * Sets the same-site restriction for the cookie
     235         *
     236         * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`)
     237         * @return static this instance for chaining
     238         */
     239        public function setSameSiteRestriction($sameSiteRestriction) {
     240                $this->sameSiteRestriction = $sameSiteRestriction;
     241
     242                return $this;
     243        }
     244
     245        /**
     246         * Saves the cookie
     247         *
     248         * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to set the cookie)
     249         */
     250        public function save() {
     251                return self::addHttpHeader((string) $this);
     252        }
     253
     254        /**
     255         * Deletes the cookie
     256         *
     257         * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to delete the cookie)
     258         */
     259        public function delete() {
     260                // create a temporary copy of this cookie so that it isn't corrupted
     261                $copiedCookie = clone $this;
     262                // set the copied cookie's value to an empty string which internally sets the required options for a deletion
     263                $copiedCookie->setValue('');
     264
     265                // save the copied "deletion" cookie
     266                return $copiedCookie->save();
     267        }
     268
     269        public function __toString() {
     270                return self::buildCookieHeader($this->name, $this->value, $this->expiryTime, $this->path, $this->domain, $this->secureOnly, $this->httpOnly, $this->sameSiteRestriction);
     271        }
     272
     273        /**
     274         * Sets a new cookie in a way compatible to PHP's `setcookie(...)` function
     275         *
     276         * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     277         * @param mixed|null $value the value of the cookie that will be stored on the client's machine
     278         * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     279         * @param string|null $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     280         * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     281         * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only
     282         * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages
     283         * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`)
     284         * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to set the cookie)
     285         */
     286        public static function setcookie($name, $value = null, $expiryTime = 0, $path = null, $domain = null, $secureOnly = false, $httpOnly = false, $sameSiteRestriction = null) {
     287                return self::addHttpHeader(
     288                        self::buildCookieHeader($name, $value, $expiryTime, $path, $domain, $secureOnly, $httpOnly, $sameSiteRestriction)
     289                );
     290        }
     291
     292        /**
     293         * Builds the HTTP header that can be used to set a cookie with the specified options
     294         *
     295         * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     296         * @param mixed|null $value the value of the cookie that will be stored on the client's machine
     297         * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     298         * @param string|null $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     299         * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     300         * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only
     301         * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages
     302         * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `Lax` or `Strict`)
     303         * @return string the HTTP header
     304         */
     305        public static function buildCookieHeader($name, $value = null, $expiryTime = 0, $path = null, $domain = null, $secureOnly = false, $httpOnly = false, $sameSiteRestriction = null) {
     306                if (self::isNameValid($name)) {
     307                        $name = (string) $name;
     308                }
     309                else {
     310                        return null;
     311                }
     312
     313                if (self::isExpiryTimeValid($expiryTime)) {
     314                        $expiryTime = (int) $expiryTime;
     315                }
     316                else {
     317                        return null;
     318                }
     319
     320                $forceShowExpiry = false;
     321
     322                if (\is_null($value) || $value === false || $value === '') {
     323                        $value = 'deleted';
     324                        $expiryTime = 0;
     325                        $forceShowExpiry = true;
     326                }
     327
     328                $maxAgeStr = self::formatMaxAge($expiryTime, $forceShowExpiry);
     329                $expiryTimeStr = self::formatExpiryTime($expiryTime, $forceShowExpiry);
     330
     331                $headerStr = self::HEADER_PREFIX . $name . '=' . \urlencode($value);
     332
     333                if (!\is_null($expiryTimeStr)) {
     334                        $headerStr .= '; expires=' . $expiryTimeStr;
     335                }
     336
     337                // The `Max-Age` property is supported on PHP 5.5+ only (https://bugs.php.net/bug.php?id=23955).
     338                if (\PHP_VERSION_ID >= 50500) {
     339                        if (!\is_null($maxAgeStr)) {
     340                                $headerStr .= '; Max-Age=' . $maxAgeStr;
     341                        }
     342                }
     343
     344                if (!empty($path) || $path === 0) {
     345                        $headerStr .= '; path=' . $path;
     346                }
     347
     348                if (!empty($domain) || $domain === 0) {
     349                        $headerStr .= '; domain=' . $domain;
     350                }
     351
     352                if ($secureOnly) {
     353                        $headerStr .= '; secure';
     354                }
     355
     356                if ($httpOnly) {
     357                        $headerStr .= '; httponly';
     358                }
     359
     360                if ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_LAX) {
     361                        $headerStr .= '; SameSite=Lax';
     362                }
     363                elseif ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_STRICT) {
     364                        $headerStr .= '; SameSite=Strict';
     365                }
     366
     367                return $headerStr;
     368        }
     369
     370        /**
     371         * Parses the given cookie header and returns an equivalent cookie instance
     372         *
     373         * @param string $cookieHeader the cookie header to parse
     374         * @return \Delight\Cookie\Cookie|null the cookie instance or `null`
     375         */
     376        public static function parse($cookieHeader) {
     377                if (empty($cookieHeader)) {
     378                        return null;
     379                }
     380
     381                if (\preg_match('/^' . self::HEADER_PREFIX . '(.*?)=(.*?)(?:; (.*?))?$/i', $cookieHeader, $matches)) {
     382                        if (\count($matches) >= 4) {
     383                                $attributes = \explode('; ', $matches[3]);
     384
     385                                $cookie = new self($matches[1]);
     386                                $cookie->setPath(null);
     387                                $cookie->setHttpOnly(false);
     388                                $cookie->setValue(
     389                                        \urldecode($matches[2])
     390                                );
     391
     392                                foreach ($attributes as $attribute) {
     393                                        if (\strcasecmp($attribute, 'HttpOnly') === 0) {
     394                                                $cookie->setHttpOnly(true);
     395                                        }
     396                                        elseif (\strcasecmp($attribute, 'Secure') === 0) {
     397                                                $cookie->setSecureOnly(true);
     398                                        }
     399                                        elseif (\stripos($attribute, 'Expires=') === 0) {
     400                                                $cookie->setExpiryTime((int) \strtotime(\substr($attribute, 8)));
     401                                        }
     402                                        elseif (\stripos($attribute, 'Domain=') === 0) {
     403                                                $cookie->setDomain(\substr($attribute, 7));
     404                                        }
     405                                        elseif (\stripos($attribute, 'Path=') === 0) {
     406                                                $cookie->setPath(\substr($attribute, 5));
     407                                        }
     408                                }
     409
     410                                return $cookie;
     411                        }
     412                        else {
     413                                return null;
     414                        }
     415                }
     416                else {
     417                        return null;
     418                }
     419        }
     420
     421        /**
     422         * Checks whether a cookie with the specified name exists
     423         *
     424         * @param string $name the name of the cookie to check
     425         * @return bool whether there is a cookie with the specified name
     426         */
     427        public static function exists($name) {
     428                return isset($_COOKIE[$name]);
     429        }
     430
     431        /**
     432         * Returns the value from the requested cookie or, if not found, the specified default value
     433         *
     434         * @param string $name the name of the cookie to retrieve the value from
     435         * @param mixed $defaultValue the default value to return if the requested cookie cannot be found
     436         * @return mixed the value from the requested cookie or the default value
     437         */
     438        public static function get($name, $defaultValue = null) {
     439                if (isset($_COOKIE[$name])) {
     440                        return $_COOKIE[$name];
     441                }
     442                else {
     443                        return $defaultValue;
     444                }
     445        }
     446
     447        private static function isNameValid($name) {
     448                $name = (string) $name;
     449
     450                // The name of a cookie must not be empty on PHP 7+ (https://bugs.php.net/bug.php?id=69523).
     451                if ($name !== '' || \PHP_VERSION_ID < 70000) {
     452                        if (!\preg_match('/[=,; \\t\\r\\n\\013\\014]/', $name)) {
     453                                return true;
     454                        }
     455                }
     456
     457                return false;
     458        }
     459
     460        private static function isExpiryTimeValid($expiryTime) {
     461                return \is_numeric($expiryTime) || \is_null($expiryTime) || \is_bool($expiryTime);
     462        }
     463
     464        private static function calculateMaxAge($expiryTime) {
     465                if ($expiryTime === 0) {
     466                        return 0;
     467                }
     468                else {
     469                        $maxAge = $expiryTime - \time();
     470
     471                        // The value of the `Max-Age` property must not be negative on PHP 7.0.19+ (< 7.1) and
     472                        // PHP 7.1.5+ (https://bugs.php.net/bug.php?id=72071).
     473                        if ((\PHP_VERSION_ID >= 70019 && \PHP_VERSION_ID < 70100) || \PHP_VERSION_ID >= 70105) {
     474                                if ($maxAge < 0) {
     475                                        $maxAge = 0;
     476                                }
     477                        }
     478
     479                        return $maxAge;
     480                }
     481        }
     482
     483        private static function formatExpiryTime($expiryTime, $forceShow = false) {
     484                if ($expiryTime > 0 || $forceShow) {
     485                        if ($forceShow) {
     486                                $expiryTime = 1;
     487                        }
     488
     489                        return \gmdate('D, d-M-Y H:i:s T', $expiryTime);
     490                }
     491                else {
     492                        return null;
     493                }
     494        }
     495
     496        private static function formatMaxAge($expiryTime, $forceShow = false) {
     497                if ($expiryTime > 0 || $forceShow) {
     498                        return (string) self::calculateMaxAge($expiryTime);
     499                }
     500                else {
     501                        return null;
     502                }
     503        }
     504
     505        private static function normalizeDomain($domain = null) {
     506                // make sure that the domain is a string
     507                $domain = (string) $domain;
     508
     509                // if the cookie should be valid for the current host only
     510                if ($domain === '') {
     511                        // no need for further normalization
     512                        return null;
     513                }
     514
     515                // if the provided domain is actually an IP address
     516                if (\filter_var($domain, \FILTER_VALIDATE_IP) !== false) {
     517                        // let the cookie be valid for the current host
     518                        return null;
     519                }
     520
     521                // for local hostnames (which either have no dot at all or a leading dot only)
     522                if (\strpos($domain, '.') === false || \strrpos($domain, '.') === 0) {
     523                        // let the cookie be valid for the current host while ensuring maximum compatibility
     524                        return null;
     525                }
     526
     527                // unless the domain already starts with a dot
     528                if ($domain[0] !== '.') {
     529                        // prepend a dot for maximum compatibility (e.g. with RFC 2109)
     530                        $domain = '.' . $domain;
     531                }
     532
     533                // return the normalized domain
     534                return $domain;
     535        }
     536
     537        private static function addHttpHeader($header) {
     538                if (!\headers_sent()) {
     539                        if (!empty($header)) {
     540                                \header($header, false);
     541
     542                                return true;
     543                        }
     544                }
     545
     546                return false;
     547        }
     548
     549}
  • wp-includes/pluggable.php

    diff --git a/wp-includes/pluggable.php b/wp-includes/pluggable.php
    index 32fab32..6d10869 100644
    a b function wp_set_auth_cookie( $user_id, $remember = false, $secure = '', $token = 
    917917                return;
    918918        }
    919919
    920         setcookie($auth_cookie_name, $auth_cookie, $expire, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN, $secure, true);
    921         setcookie($auth_cookie_name, $auth_cookie, $expire, ADMIN_COOKIE_PATH, COOKIE_DOMAIN, $secure, true);
    922         setcookie(LOGGED_IN_COOKIE, $logged_in_cookie, $expire, COOKIEPATH, COOKIE_DOMAIN, $secure_logged_in_cookie, true);
     920        Cookie::setcookie($auth_cookie_name, $auth_cookie, $expire, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN, $secure, true, apply_filters('samesite_cookie', 'Lax'));
     921        Cookie::setcookie($auth_cookie_name, $auth_cookie, $expire, ADMIN_COOKIE_PATH, COOKIE_DOMAIN, $secure, true, apply_filters('samesite_cookie', 'Lax'));
     922        Cookie::setcookie(LOGGED_IN_COOKIE, $logged_in_cookie, $expire, COOKIEPATH, COOKIE_DOMAIN, $secure_logged_in_cookie, true, apply_filters('samesite_cookie', 'Lax'));
    923923        if ( COOKIEPATH != SITECOOKIEPATH )
    924                 setcookie(LOGGED_IN_COOKIE, $logged_in_cookie, $expire, SITECOOKIEPATH, COOKIE_DOMAIN, $secure_logged_in_cookie, true);
     924                Cookie::setcookie(LOGGED_IN_COOKIE, $logged_in_cookie, $expire, SITECOOKIEPATH, COOKIE_DOMAIN, $secure_logged_in_cookie, true, apply_filters('samesite_cookie', 'Lax'));
    925925}
    926926endif;
    927927
  • wp-settings.php

    diff --git a/wp-settings.php b/wp-settings.php
    index bacf4cf..052ed21 100644
    a b require( ABSPATH . WPINC . '/class-wp-widget-factory.php' ); 
    219219require( ABSPATH . WPINC . '/nav-menu.php' );
    220220require( ABSPATH . WPINC . '/nav-menu-template.php' );
    221221require( ABSPATH . WPINC . '/admin-bar.php' );
     222require( ABSPATH . WPINC . '/class-cookie.php' );
    222223require( ABSPATH . WPINC . '/rest-api.php' );
    223224require( ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php' );
    224225require( ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php' );