Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialWikimediaDebug
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 11
756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
12
 submit
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addHelpLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCookie
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 setCookie
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 clearCookie
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCookieOptions
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getCookieDomainForDisplay
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace WikimediaEvents;
4
5use ErrorPageError;
6use ExtensionRegistry;
7use HTMLForm;
8use MediaWiki\MainConfigNames;
9use MediaWiki\SpecialPage\UnlistedSpecialPage;
10use MediaWiki\Utils\MWTimestamp;
11use Message;
12use RequestContext;
13
14/**
15 * Manage the X-Wikimedia-Debug cookie, to enable verbose logging on production servers.
16 *
17 * <https://wikitech.wikimedia.org/wiki/WikimediaDebug>
18 */
19class SpecialWikimediaDebug extends UnlistedSpecialPage {
20
21    /**
22     * How far expiry is allowed to be in the future.
23     *
24     * Using the debug cookie unnecessarily is both extra server load (since it circumvents
25     * the edge cache) and logspam on the debug servers which are relied on to verify backports.
26     * We make it hard to accidentally (or intentionally) set this cookie for a very long time.
27     *
28     * By default we set the cookie for 1 hour. It can be renewed by visiting this page.
29     *
30     * @see XWikimediaDebug::MAX_EXPIRY in operations/mediawiki-config.git,
31     * which explicitly rejects cookies that have expiry longer than 24 hours.
32     *
33     */
34    private const MAX_EXPIRY = 24 * 3600;
35    private const DEFAULT_EXPIRY = 3600;
36
37    private ExtensionRegistry $extensionRegistry;
38
39    public function __construct(
40        ExtensionRegistry $extensionRegistry
41    ) {
42        parent::__construct( 'WikimediaDebug' );
43        $this->extensionRegistry = $extensionRegistry;
44    }
45
46    /**
47     * @param string|null $par Subpage
48     */
49    public function execute( $par ) {
50        if ( !$this->getConfig()->get( 'WMEWikimediaDebugBackend' ) ) {
51            throw new ErrorPageError( 'badaccess', 'wikimediaevents-special-wikimediadebug-notenabled' );
52        }
53        $this->setHeaders();
54        $this->outputHeader();
55
56        $cookieData = $this->getCookie();
57
58        $form = HTMLForm::factory( 'ooui', [], $this->getContext() );
59        $form->setSubmitCallback( [ $this, 'submit' ] );
60        if ( !$cookieData ) {
61            $form->setSubmitTextMsg( $this->msg(
62                'wikimediaevents-special-wikimediadebug-submit-set',
63                Message::durationParam( self::DEFAULT_EXPIRY ) ) );
64            $form->addHeaderHtml( $this->msg(
65                'wikimediaevents-special-wikimediadebug-header-inactive',
66                $this->getCookieDomainForDisplay()
67            )->parseAsBlock() );
68        } else {
69            $form->setSubmitDestructive();
70            $form->setSubmitName( 'clear' );
71            $form->setSubmitTextMsg( $this->msg( 'wikimediaevents-special-wikimediadebug-submit-clear' ) );
72            $form->addHeaderHtml( $this->msg(
73                'wikimediaevents-special-wikimediadebug-header-active',
74                $this->getCookieDomainForDisplay(),
75                Message::dateTimeParam( $cookieData['expire'] )
76            )->parseAsBlock() );
77
78            $form->addButton( [
79                'name' => 'renew',
80                'value' => '1',
81                'label-message' => [ 'wikimediaevents-special-wikimediadebug-submit-renew',
82                    Message::durationParam( self::DEFAULT_EXPIRY )
83                ],
84            ] );
85        }
86
87        $form->show();
88    }
89
90    /**
91     * @param array $data
92     * @param HTMLForm $form
93     * @return true
94     */
95    public function submit( array $data, HTMLForm $form ) {
96        if ( $form->getRequest()->getCheck( 'clear' ) ) {
97            $this->clearCookie();
98        } else {
99            $this->setCookie( [
100                'backend' => $this->getConfig()->get( 'WMEWikimediaDebugBackend' ),
101                'log' => true,
102            ] );
103        }
104        $this->getOutput()->redirect( $this->getPageTitle()->getLocalURL() );
105        return true;
106    }
107
108    /** @inheritDoc */
109    public function getDescription() {
110        return $this->msg( 'wikimediaevents-special-wikimediadebug-desc' );
111    }
112
113    /** @inheritDoc */
114    protected function getGroupName() {
115        return 'specialpages-group-developer';
116    }
117
118    /** @inheritDoc */
119    public function addHelpLink( $to, $overrideBaseUrl = false ) {
120        $this->getOutput()->addHelpLink( 'https://wikitech.wikimedia.org/wiki/WikimediaDebug', true );
121    }
122
123    public function getCookie(): ?array {
124        $cookieString = RequestContext::getMain()->getRequest()->getCookie( 'X-Wikimedia-Debug', '' );
125        if ( $cookieString === null ) {
126            return null;
127        }
128        $cookieData = [];
129        foreach ( explode( ';', rawurldecode( $cookieString ) ) as $pair ) {
130            $pair = explode( '=', $pair, 2 );
131            if ( count( $pair ) === 2 ) {
132                $cookieData[trim( $pair[0] )] = trim( $pair[1] );
133            } else {
134                $cookieData[trim( $pair[0] )] = true;
135            }
136        }
137        $expire = $cookieData['expire'] ?? 0;
138        if ( $expire < MWTimestamp::time() || $expire > MWTimestamp::time() + self::MAX_EXPIRY ) {
139            // Proactively delete the cookie for the benefit of edge routing.
140            // It is ignored by wmf-config either way.
141            $this->clearCookie();
142            return null;
143        }
144        return $cookieData;
145    }
146
147    private function setCookie( array $cookieData ): void {
148        $expiry = time() + self::DEFAULT_EXPIRY;
149        $cookieData['expire'] = $expiry;
150        $cookieStringParts = [];
151        foreach ( $cookieData as $key => $value ) {
152            if ( $value === true ) {
153                $cookieStringParts[] = $key;
154            } else {
155                $cookieStringParts[] = $key . '=' . $value;
156            }
157        }
158        $this->getRequest()->response()->setCookie(
159            'X-Wikimedia-Debug',
160            implode( ';', $cookieStringParts ),
161            $expiry,
162            $this->getCookieOptions()
163        );
164    }
165
166    private function clearCookie(): void {
167        $this->getRequest()->response()->clearCookie(
168            'X-Wikimedia-Debug',
169            $this->getCookieOptions()
170        );
171    }
172
173    private function getCookieOptions(): array {
174        $options = [
175            'prefix' => '',
176        ];
177        // SameSite will prevent the cookie from being set if it's not also Secure.
178        // Cannot happen in production but makes local development confusing.
179        if ( $this->getConfig()->get( MainConfigNames::CookieSecure ) ) {
180            $options['sameSite'] = 'none';
181        }
182        if ( $this->extensionRegistry->isLoaded( 'CentralAuth' )
183            && $this->getConfig()->get( 'CentralAuthCookieDomain' )
184        ) {
185            $options['domain'] = $this->getConfig()->get( 'CentralAuthCookieDomain' );
186        }
187        return $options;
188    }
189
190    private function getCookieDomainForDisplay(): string {
191        $cookieDomain = '';
192        if ( $this->extensionRegistry->isLoaded( 'CentralAuth' ) ) {
193            $cookieDomain = $this->getConfig()->get( 'CentralAuthCookieDomain' );
194        }
195        $cookieDomain = $cookieDomain
196            ?: $this->getConfig()->get( MainConfigNames::CookieDomain )
197            ?: (string)$this->getRequest()->getHeader( 'Host' );
198        return $cookieDomain;
199    }
200
201}