Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.43% covered (warning)
52.43%
54 / 103
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebResponse
52.94% covered (warning)
52.94%
54 / 102
33.33% covered (danger)
33.33%
3 / 9
131.15
0.00% covered (danger)
0.00%
0 / 1
 disableForPostSend
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 header
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getStatusCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeader
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 statusHeader
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
3.46
 headersSent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCookie
52.05% covered (warning)
52.05%
38 / 73
0.00% covered (danger)
0.00%
0 / 1
53.71
 clearCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCookies
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Classes used to send headers and cookies back to the user
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Request;
24
25use HttpStatus;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\MainConfigNames;
28use MediaWiki\MediaWikiServices;
29use RuntimeException;
30
31/**
32 * Allow programs to request this object from WebRequest::response()
33 * and handle all outputting (or lack of outputting) via it.
34 * @ingroup HTTP
35 */
36class WebResponse {
37
38    /** @var array Used to record set cookies, because PHP's setcookie() will
39     * happily send an identical Set-Cookie to the client.
40     */
41    protected static $setCookies = [];
42
43    /** @var bool Used to disable setters before running jobs post-request (T191537) */
44    protected $disableForPostSend = false;
45
46    /**
47     * Disable setters for post-send processing
48     *
49     * After this call, self::setCookie(), self::header(), and
50     * self::statusHeader() will log a warning and return without
51     * setting cookies or headers.
52     *
53     * @since 1.32 (non-static since 1.42)
54     */
55    public function disableForPostSend() {
56        $this->disableForPostSend = true;
57    }
58
59    /**
60     * Output an HTTP header, wrapper for PHP's header()
61     * @param string $string Header to output
62     * @param bool $replace Replace current similar header
63     * @param null|int $http_response_code Forces the HTTP response code to the specified value.
64     */
65    public function header( $string, $replace = true, $http_response_code = null ) {
66        if ( $this->disableForPostSend ) {
67            wfDebugLog( 'header', 'ignored post-send header {header}', 'all', [
68                'header' => $string,
69                'replace' => $replace,
70                'http_response_code' => $http_response_code,
71                'exception' => new RuntimeException( 'Ignored post-send header' ),
72            ] );
73            return;
74        }
75
76        \MediaWiki\Request\HeaderCallback::warnIfHeadersSent();
77        if ( $http_response_code ) {
78            header( $string, $replace, $http_response_code );
79        } else {
80            header( $string, $replace );
81        }
82    }
83
84    /**
85     * @see http_response_code
86     * @return int|bool
87     * @since 1.42
88     */
89    public function getStatusCode() {
90        return http_response_code();
91    }
92
93    /**
94     * Get a response header
95     * @param string $key The name of the header to get (case insensitive).
96     * @return string|null The header value (if set); null otherwise.
97     * @since 1.25
98     */
99    public function getHeader( $key ) {
100        foreach ( headers_list() as $header ) {
101            [ $name, $val ] = explode( ':', $header, 2 );
102            if ( !strcasecmp( $name, $key ) ) {
103                return trim( $val );
104            }
105        }
106        return null;
107    }
108
109    /**
110     * Output an HTTP status code header
111     * @since 1.26
112     * @param int $code Status code
113     */
114    public function statusHeader( $code ) {
115        if ( $this->disableForPostSend ) {
116            wfDebugLog( 'header', 'ignored post-send status header {code}', 'all', [
117                'code' => $code,
118                'exception' => new RuntimeException( 'Ignored post-send status header' ),
119            ] );
120            return;
121        }
122
123        HttpStatus::header( $code );
124    }
125
126    /**
127     * Test if headers have been sent
128     * @since 1.27
129     * @return bool
130     */
131    public function headersSent() {
132        return headers_sent();
133    }
134
135    /**
136     * Set the browser cookie
137     *
138     * @param string $name The name of the cookie.
139     * @param string $value The value to be stored in the cookie.
140     * @param int|null $expire Unix timestamp (in seconds) when the cookie should expire.
141     *   - 0 (the default) causes it to expire $wgCookieExpiration seconds from now.
142     *   - null causes it to be a session cookie.
143     * @param array $options Assoc of additional cookie options:
144     *   - prefix: string, name prefix ($wgCookiePrefix)
145     *   - domain: string, cookie domain ($wgCookieDomain)
146     *   - path: string, cookie path ($wgCookiePath)
147     *   - secure: bool, secure attribute ($wgCookieSecure)
148     *   - httpOnly: bool, httpOnly attribute ($wgCookieHttpOnly)
149     *   - raw: bool, true to suppress encoding of the value
150     *   - sameSite: string|null, SameSite attribute. May be "strict", "lax",
151     *     "none", or null or "" for no attribute. (default absent)
152     *   - sameSiteLegacy: bool|null, this option is now ignored
153     * @since 1.22 Replaced $prefix, $domain, and $forceSecure with $options
154     */
155    public function setCookie( $name, $value, $expire = 0, $options = [] ) {
156        $services = MediaWikiServices::getInstance();
157        $mainConfig = $services->getMainConfig();
158        $cookiePath = $mainConfig->get( MainConfigNames::CookiePath );
159        $cookiePrefix = $mainConfig->get( MainConfigNames::CookiePrefix );
160        $cookieDomain = $mainConfig->get( MainConfigNames::CookieDomain );
161        $cookieSecure = $mainConfig->get( MainConfigNames::CookieSecure );
162        $cookieExpiration = $mainConfig->get( MainConfigNames::CookieExpiration );
163        $cookieHttpOnly = $mainConfig->get( MainConfigNames::CookieHttpOnly );
164        $options = array_filter( $options, static function ( $a ) {
165            return $a !== null;
166        } ) + [
167            'prefix' => $cookiePrefix,
168            'domain' => $cookieDomain,
169            'path' => $cookiePath,
170            'secure' => $cookieSecure,
171            'httpOnly' => $cookieHttpOnly,
172            'raw' => false,
173            'sameSite' => '',
174        ];
175
176        if ( $expire === null ) {
177            $expire = 0; // Session cookie
178        } elseif ( $expire == 0 && $cookieExpiration != 0 ) {
179            $expire = time() + $cookieExpiration;
180        }
181
182        if ( $this->disableForPostSend ) {
183            $prefixedName = $options['prefix'] . $name;
184            wfDebugLog( 'cookie', 'ignored post-send cookie {cookie}', 'all', [
185                'cookie' => $prefixedName,
186                'data' => [
187                    'name' => $prefixedName,
188                    'value' => (string)$value,
189                    'expire' => (int)$expire,
190                    'path' => (string)$options['path'],
191                    'domain' => (string)$options['domain'],
192                    'secure' => (bool)$options['secure'],
193                    'httpOnly' => (bool)$options['httpOnly'],
194                    'sameSite' => (string)$options['sameSite']
195                ],
196                'exception' => new RuntimeException( 'Ignored post-send cookie' ),
197            ] );
198            return;
199        }
200
201        $hookRunner = new HookRunner( $services->getHookContainer() );
202        if ( !$hookRunner->onWebResponseSetCookie( $name, $value, $expire, $options ) ) {
203            return;
204        }
205
206        // Note: Don't try to move this earlier to reuse it for $this->disableForPostSend,
207        // we need to use the altered values from the hook here. (T198525)
208        $prefixedName = $options['prefix'] . $name;
209        $value = (string)$value;
210        $func = $options['raw'] ? 'setrawcookie' : 'setcookie';
211        $setOptions = [
212            'expires' => (int)$expire,
213            'path' => (string)$options['path'],
214            'domain' => (string)$options['domain'],
215            'secure' => (bool)$options['secure'],
216            'httponly' => (bool)$options['httpOnly'],
217            'samesite' => (string)$options['sameSite'],
218        ];
219
220        // Per RFC 6265, key is name + domain + path
221        $key = "{$prefixedName}\n{$setOptions['domain']}\n{$setOptions['path']}";
222
223        // If this cookie name was in the request, fake an entry in
224        // self::$setCookies for it so the deleting check works right.
225        if ( isset( $_COOKIE[$prefixedName] ) && !array_key_exists( $key, self::$setCookies ) ) {
226            self::$setCookies[$key] = [];
227        }
228
229        // PHP deletes if value is the empty string; also, a past expiry is deleting
230        $deleting = ( $value === '' || ( $setOptions['expires'] > 0 && $setOptions['expires'] <= time() ) );
231
232        $logDesc = "$func: \"$prefixedName\", \"$value\", \"" .
233            implode( '", "', array_map( 'strval', $setOptions ) ) . '"';
234        $optionsForDeduplication = [ $func, $prefixedName, $value, $setOptions ];
235
236        if ( $deleting && !isset( self::$setCookies[$key] ) ) { // isset( null ) is false
237            wfDebugLog( 'cookie', "already deleted $logDesc" );
238            return;
239        } elseif ( !$deleting && isset( self::$setCookies[$key] ) &&
240            self::$setCookies[$key] === $optionsForDeduplication
241        ) {
242            wfDebugLog( 'cookie', "already set $logDesc" );
243            return;
244        }
245
246        wfDebugLog( 'cookie', $logDesc );
247        if ( $func === 'setrawcookie' ) {
248            setrawcookie( $prefixedName, $value, $setOptions );
249        } else {
250            setcookie( $prefixedName, $value, $setOptions );
251        }
252        self::$setCookies[$key] = $deleting ? null : $optionsForDeduplication;
253    }
254
255    /**
256     * Unset a browser cookie.
257     * This sets the cookie with an empty value and an expiry set to a time in the past,
258     * which will cause the browser to remove any cookie with the given name, domain and
259     * path from its cookie store. Options other than these (and prefix) have no effect.
260     * @param string $name Cookie name
261     * @param array $options Cookie options, see {@link setCookie()}
262     * @since 1.27
263     */
264    public function clearCookie( $name, $options = [] ) {
265        $this->setCookie( $name, '', time() - 31_536_000 /* 1 year */, $options );
266    }
267
268    /**
269     * Checks whether this request is performing cookie operations
270     *
271     * @return bool
272     * @since 1.27
273     */
274    public function hasCookies() {
275        return (bool)self::$setCookies;
276    }
277}
278
279/** @deprecated class alias since 1.40 */
280class_alias( WebResponse::class, 'WebResponse' );