This project does not seem to handle request data directly as such no vulnerable execution paths were found.
include
, or for example
via PHP's auto-loading mechanism.
These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more
1 | <?php |
||
2 | /** |
||
3 | * This program is free software; you can redistribute it and/or modify |
||
4 | * it under the terms of the GNU General Public License as published by |
||
5 | * the Free Software Foundation; either version 2 of the License, or |
||
6 | * (at your option) any later version. |
||
7 | * |
||
8 | * This program is distributed in the hope that it will be useful, |
||
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
11 | * GNU General Public License for more details. |
||
12 | * |
||
13 | * You should have received a copy of the GNU General Public License along |
||
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
||
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
16 | * http://www.gnu.org/copyleft/gpl.html |
||
17 | * |
||
18 | * @file |
||
19 | * @author Aaron Schulz |
||
20 | */ |
||
21 | |||
22 | use MediaWiki\Logger\LoggerFactory; |
||
23 | use MediaWiki\MediaWikiServices; |
||
24 | use Wikimedia\ScopedCallback; |
||
0 ignored issues
–
show
|
|||
25 | |||
26 | /** |
||
27 | * Prepare an edit in shared cache so that it can be reused on edit |
||
28 | * |
||
29 | * This endpoint can be called via AJAX as the user focuses on the edit |
||
30 | * summary box. By the time of submission, the parse may have already |
||
31 | * finished, and can be immediately used on page save. Certain parser |
||
32 | * functions like {{REVISIONID}} or {{CURRENTTIME}} may cause the cache |
||
33 | * to not be used on edit. Template and files used are check for changes |
||
34 | * since the output was generated. The cache TTL is also kept low for sanity. |
||
35 | * |
||
36 | * @ingroup API |
||
37 | * @since 1.25 |
||
38 | */ |
||
39 | class ApiStashEdit extends ApiBase { |
||
40 | const ERROR_NONE = 'stashed'; |
||
41 | const ERROR_PARSE = 'error_parse'; |
||
42 | const ERROR_CACHE = 'error_cache'; |
||
43 | const ERROR_UNCACHEABLE = 'uncacheable'; |
||
44 | const ERROR_BUSY = 'busy'; |
||
45 | |||
46 | const PRESUME_FRESH_TTL_SEC = 30; |
||
47 | const MAX_CACHE_TTL = 300; // 5 minutes |
||
48 | |||
49 | public function execute() { |
||
50 | $user = $this->getUser(); |
||
51 | $params = $this->extractRequestParams(); |
||
52 | |||
53 | if ( $user->isBot() ) { // sanity |
||
54 | $this->dieUsage( 'This interface is not supported for bots', 'botsnotsupported' ); |
||
55 | } |
||
56 | |||
57 | $cache = ObjectCache::getLocalClusterInstance(); |
||
58 | $page = $this->getTitleOrPageId( $params ); |
||
59 | $title = $page->getTitle(); |
||
60 | |||
61 | if ( !ContentHandler::getForModelID( $params['contentmodel'] ) |
||
62 | ->isSupportedFormat( $params['contentformat'] ) |
||
63 | ) { |
||
64 | $this->dieUsage( 'Unsupported content model/format', 'badmodelformat' ); |
||
65 | } |
||
66 | |||
67 | $text = null; |
||
68 | $textHash = null; |
||
69 | if ( strlen( $params['stashedtexthash'] ) ) { |
||
70 | // Load from cache since the client indicates the text is the same as last stash |
||
71 | $textHash = $params['stashedtexthash']; |
||
72 | $textKey = $cache->makeKey( 'stashedit', 'text', $textHash ); |
||
73 | $text = $cache->get( $textKey ); |
||
74 | if ( !is_string( $text ) ) { |
||
75 | $this->dieUsage( 'No stashed text found with the given hash', 'missingtext' ); |
||
76 | } |
||
77 | } elseif ( $params['text'] !== null ) { |
||
78 | // Trim and fix newlines so the key SHA1's match (see WebRequest::getText()) |
||
79 | $text = rtrim( str_replace( "\r\n", "\n", $params['text'] ) ); |
||
80 | $textHash = sha1( $text ); |
||
81 | } else { |
||
82 | $this->dieUsage( |
||
83 | 'The text or stashedtexthash parameter must be given', 'missingtextparam' ); |
||
84 | } |
||
85 | |||
86 | $textContent = ContentHandler::makeContent( |
||
87 | $text, $title, $params['contentmodel'], $params['contentformat'] ); |
||
88 | |||
89 | $page = WikiPage::factory( $title ); |
||
90 | if ( $page->exists() ) { |
||
91 | // Page exists: get the merged content with the proposed change |
||
92 | $baseRev = Revision::newFromPageId( $page->getId(), $params['baserevid'] ); |
||
93 | if ( !$baseRev ) { |
||
94 | $this->dieUsage( "No revision ID {$params['baserevid']}", 'missingrev' ); |
||
95 | } |
||
96 | $currentRev = $page->getRevision(); |
||
97 | if ( !$currentRev ) { |
||
98 | $this->dieUsage( "No current revision of page ID {$page->getId()}", 'missingrev' ); |
||
99 | } |
||
100 | // Merge in the new version of the section to get the proposed version |
||
101 | $editContent = $page->replaceSectionAtRev( |
||
102 | $params['section'], |
||
103 | $textContent, |
||
104 | $params['sectiontitle'], |
||
105 | $baseRev->getId() |
||
106 | ); |
||
107 | if ( !$editContent ) { |
||
108 | $this->dieUsage( 'Could not merge updated section.', 'replacefailed' ); |
||
109 | } |
||
110 | if ( $currentRev->getId() == $baseRev->getId() ) { |
||
111 | // Base revision was still the latest; nothing to merge |
||
112 | $content = $editContent; |
||
113 | } else { |
||
114 | // Merge the edit into the current version |
||
115 | $baseContent = $baseRev->getContent(); |
||
116 | $currentContent = $currentRev->getContent(); |
||
117 | if ( !$baseContent || !$currentContent ) { |
||
118 | $this->dieUsage( "Missing content for page ID {$page->getId()}", 'missingrev' ); |
||
119 | } |
||
120 | $handler = ContentHandler::getForModelID( $baseContent->getModel() ); |
||
121 | $content = $handler->merge3( $baseContent, $editContent, $currentContent ); |
||
122 | } |
||
123 | } else { |
||
124 | // New pages: use the user-provided content model |
||
125 | $content = $textContent; |
||
126 | } |
||
127 | |||
128 | if ( !$content ) { // merge3() failed |
||
129 | $this->getResult()->addValue( null, |
||
130 | $this->getModuleName(), [ 'status' => 'editconflict' ] ); |
||
131 | return; |
||
132 | } |
||
133 | |||
134 | // The user will abort the AJAX request by pressing "save", so ignore that |
||
135 | ignore_user_abort( true ); |
||
136 | |||
137 | if ( $user->pingLimiter( 'stashedit' ) ) { |
||
138 | $status = 'ratelimited'; |
||
139 | } else { |
||
140 | $status = self::parseAndStash( $page, $content, $user, $params['summary'] ); |
||
0 ignored issues
–
show
It seems like
$page defined by \WikiPage::factory($title) on line 89 can be null ; however, ApiStashEdit::parseAndStash() does not accept null , maybe add an additional type check?
Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code: /** @return stdClass|null */
function mayReturnNull() { }
function doesNotAcceptNull(stdClass $x) { }
// With potential error.
function withoutCheck() {
$x = mayReturnNull();
doesNotAcceptNull($x); // Potential error here.
}
// Safe - Alternative 1
function withCheck1() {
$x = mayReturnNull();
if ( ! $x instanceof stdClass) {
throw new \LogicException('$x must be defined.');
}
doesNotAcceptNull($x);
}
// Safe - Alternative 2
function withCheck2() {
$x = mayReturnNull();
if ($x instanceof stdClass) {
doesNotAcceptNull($x);
}
}
![]() |
|||
141 | $textKey = $cache->makeKey( 'stashedit', 'text', $textHash ); |
||
142 | $cache->set( $textKey, $text, self::MAX_CACHE_TTL ); |
||
143 | } |
||
144 | |||
145 | $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); |
||
146 | $stats->increment( "editstash.cache_stores.$status" ); |
||
147 | |||
148 | $this->getResult()->addValue( |
||
149 | null, |
||
150 | $this->getModuleName(), |
||
151 | [ |
||
152 | 'status' => $status, |
||
153 | 'texthash' => $textHash |
||
154 | ] |
||
155 | ); |
||
156 | } |
||
157 | |||
158 | /** |
||
159 | * @param WikiPage $page |
||
160 | * @param Content $content Edit content |
||
161 | * @param User $user |
||
162 | * @param string $summary Edit summary |
||
163 | * @return integer ApiStashEdit::ERROR_* constant |
||
164 | * @since 1.25 |
||
165 | */ |
||
166 | public static function parseAndStash( WikiPage $page, Content $content, User $user, $summary ) { |
||
167 | $cache = ObjectCache::getLocalClusterInstance(); |
||
168 | $logger = LoggerFactory::getInstance( 'StashEdit' ); |
||
169 | |||
170 | $title = $page->getTitle(); |
||
171 | $key = self::getStashKey( $title, self::getContentHash( $content ), $user ); |
||
172 | |||
173 | // Use the master DB for fast blocking locks |
||
174 | $dbw = wfGetDB( DB_MASTER ); |
||
175 | if ( !$dbw->lock( $key, __METHOD__, 1 ) ) { |
||
176 | // De-duplicate requests on the same key |
||
177 | return self::ERROR_BUSY; |
||
178 | } |
||
179 | /** @noinspection PhpUnusedLocalVariableInspection */ |
||
180 | $unlocker = new ScopedCallback( function () use ( $dbw, $key ) { |
||
0 ignored issues
–
show
$unlocker is not used, you could remove the assignment.
This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently. $myVar = 'Value';
$higher = false;
if (rand(1, 6) > 3) {
$higher = true;
} else {
$higher = false;
}
Both the ![]() |
|||
181 | $dbw->unlock( $key, __METHOD__ ); |
||
182 | } ); |
||
183 | |||
184 | $cutoffTime = time() - self::PRESUME_FRESH_TTL_SEC; |
||
185 | |||
186 | // Reuse any freshly build matching edit stash cache |
||
187 | $editInfo = $cache->get( $key ); |
||
188 | if ( $editInfo && wfTimestamp( TS_UNIX, $editInfo->timestamp ) >= $cutoffTime ) { |
||
189 | $alreadyCached = true; |
||
190 | } else { |
||
191 | $format = $content->getDefaultFormat(); |
||
192 | $editInfo = $page->prepareContentForEdit( $content, null, $user, $format, false ); |
||
193 | $alreadyCached = false; |
||
194 | } |
||
195 | |||
196 | if ( $editInfo && $editInfo->output ) { |
||
197 | // Let extensions add ParserOutput metadata or warm other caches |
||
198 | Hooks::run( 'ParserOutputStashForEdit', |
||
199 | [ $page, $content, $editInfo->output, $summary, $user ] ); |
||
200 | |||
201 | if ( $alreadyCached ) { |
||
202 | $logger->debug( "Already cached parser output for key '$key' ('$title')." ); |
||
203 | return self::ERROR_NONE; |
||
204 | } |
||
205 | |||
206 | list( $stashInfo, $ttl, $code ) = self::buildStashValue( |
||
207 | $editInfo->pstContent, |
||
208 | $editInfo->output, |
||
209 | $editInfo->timestamp, |
||
210 | $user |
||
211 | ); |
||
212 | |||
213 | if ( $stashInfo ) { |
||
214 | $ok = $cache->set( $key, $stashInfo, $ttl ); |
||
215 | if ( $ok ) { |
||
216 | $logger->debug( "Cached parser output for key '$key' ('$title')." ); |
||
217 | return self::ERROR_NONE; |
||
218 | } else { |
||
219 | $logger->error( "Failed to cache parser output for key '$key' ('$title')." ); |
||
220 | return self::ERROR_CACHE; |
||
221 | } |
||
222 | } else { |
||
223 | $logger->info( "Uncacheable parser output for key '$key' ('$title') [$code]." ); |
||
224 | return self::ERROR_UNCACHEABLE; |
||
225 | } |
||
226 | } |
||
227 | |||
228 | return self::ERROR_PARSE; |
||
229 | } |
||
230 | |||
231 | /** |
||
232 | * Check that a prepared edit is in cache and still up-to-date |
||
233 | * |
||
234 | * This method blocks if the prepared edit is already being rendered, |
||
235 | * waiting until rendering finishes before doing final validity checks. |
||
236 | * |
||
237 | * The cache is rejected if template or file changes are detected. |
||
238 | * Note that foreign template or file transclusions are not checked. |
||
239 | * |
||
240 | * The result is a map (pstContent,output,timestamp) with fields |
||
241 | * extracted directly from WikiPage::prepareContentForEdit(). |
||
242 | * |
||
243 | * @param Title $title |
||
244 | * @param Content $content |
||
245 | * @param User $user User to get parser options from |
||
246 | * @return stdClass|bool Returns false on cache miss |
||
247 | */ |
||
248 | public static function checkCache( Title $title, Content $content, User $user ) { |
||
249 | if ( $user->isBot() ) { |
||
250 | return false; // bots never stash - don't pollute stats |
||
251 | } |
||
252 | |||
253 | $cache = ObjectCache::getLocalClusterInstance(); |
||
254 | $logger = LoggerFactory::getInstance( 'StashEdit' ); |
||
255 | $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); |
||
256 | |||
257 | $key = self::getStashKey( $title, self::getContentHash( $content ), $user ); |
||
258 | $editInfo = $cache->get( $key ); |
||
259 | if ( !is_object( $editInfo ) ) { |
||
260 | $start = microtime( true ); |
||
261 | // We ignore user aborts and keep parsing. Block on any prior parsing |
||
262 | // so as to use its results and make use of the time spent parsing. |
||
263 | // Skip this logic if there no master connection in case this method |
||
264 | // is called on an HTTP GET request for some reason. |
||
265 | $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); |
||
266 | $dbw = $lb->getAnyOpenConnection( $lb->getWriterIndex() ); |
||
267 | if ( $dbw && $dbw->lock( $key, __METHOD__, 30 ) ) { |
||
268 | $editInfo = $cache->get( $key ); |
||
269 | $dbw->unlock( $key, __METHOD__ ); |
||
270 | } |
||
271 | |||
272 | $timeMs = 1000 * max( 0, microtime( true ) - $start ); |
||
273 | $stats->timing( 'editstash.lock_wait_time', $timeMs ); |
||
274 | } |
||
275 | |||
276 | if ( !is_object( $editInfo ) || !$editInfo->output ) { |
||
277 | $stats->increment( 'editstash.cache_misses.no_stash' ); |
||
278 | $logger->debug( "Empty cache for key '$key' ('$title'); user '{$user->getName()}'." ); |
||
279 | return false; |
||
280 | } |
||
281 | |||
282 | $age = time() - wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() ); |
||
283 | if ( $age <= self::PRESUME_FRESH_TTL_SEC ) { |
||
284 | // Assume nothing changed in this time |
||
285 | $stats->increment( 'editstash.cache_hits.presumed_fresh' ); |
||
286 | $logger->debug( "Timestamp-based cache hit for key '$key' (age: $age sec)." ); |
||
287 | } elseif ( isset( $editInfo->edits ) && $editInfo->edits === $user->getEditCount() ) { |
||
288 | // Logged-in user made no local upload/template edits in the meantime |
||
289 | $stats->increment( 'editstash.cache_hits.presumed_fresh' ); |
||
290 | $logger->debug( "Edit count based cache hit for key '$key' (age: $age sec)." ); |
||
291 | } elseif ( $user->isAnon() |
||
292 | && self::lastEditTime( $user ) < $editInfo->output->getCacheTime() |
||
293 | ) { |
||
294 | // Logged-out user made no local upload/template edits in the meantime |
||
295 | $stats->increment( 'editstash.cache_hits.presumed_fresh' ); |
||
296 | $logger->debug( "Edit check based cache hit for key '$key' (age: $age sec)." ); |
||
297 | } else { |
||
298 | // User may have changed included content |
||
299 | $editInfo = false; |
||
300 | } |
||
301 | |||
302 | if ( !$editInfo ) { |
||
303 | $stats->increment( 'editstash.cache_misses.proven_stale' ); |
||
304 | $logger->info( "Stale cache for key '$key'; old key with outside edits. (age: $age sec)" ); |
||
305 | } elseif ( $editInfo->output->getFlag( 'vary-revision' ) ) { |
||
306 | // This can be used for the initial parse, e.g. for filters or doEditContent(), |
||
307 | // but a second parse will be triggered in doEditUpdates(). This is not optimal. |
||
308 | $logger->info( "Cache for key '$key' ('$title') has vary_revision." ); |
||
309 | } elseif ( $editInfo->output->getFlag( 'vary-revision-id' ) ) { |
||
310 | // Similar to the above if we didn't guess the ID correctly. |
||
311 | $logger->info( "Cache for key '$key' ('$title') has vary_revision_id." ); |
||
312 | } |
||
313 | |||
314 | return $editInfo; |
||
315 | } |
||
316 | |||
317 | /** |
||
318 | * @param User $user |
||
319 | * @return string|null TS_MW timestamp or null |
||
320 | */ |
||
321 | private static function lastEditTime( User $user ) { |
||
322 | $time = wfGetDB( DB_REPLICA )->selectField( |
||
323 | 'recentchanges', |
||
324 | 'MAX(rc_timestamp)', |
||
325 | [ 'rc_user_text' => $user->getName() ], |
||
326 | __METHOD__ |
||
327 | ); |
||
328 | |||
329 | return wfTimestampOrNull( TS_MW, $time ); |
||
330 | } |
||
331 | |||
332 | /** |
||
333 | * Get hash of the content, factoring in model/format |
||
334 | * |
||
335 | * @param Content $content |
||
336 | * @return string |
||
337 | */ |
||
338 | private static function getContentHash( Content $content ) { |
||
339 | return sha1( implode( "\n", [ |
||
340 | $content->getModel(), |
||
341 | $content->getDefaultFormat(), |
||
342 | $content->serialize( $content->getDefaultFormat() ) |
||
343 | ] ) ); |
||
344 | } |
||
345 | |||
346 | /** |
||
347 | * Get the temporary prepared edit stash key for a user |
||
348 | * |
||
349 | * This key can be used for caching prepared edits provided: |
||
350 | * - a) The $user was used for PST options |
||
351 | * - b) The parser output was made from the PST using cannonical matching options |
||
352 | * |
||
353 | * @param Title $title |
||
354 | * @param string $contentHash Result of getContentHash() |
||
355 | * @param User $user User to get parser options from |
||
356 | * @return string |
||
357 | */ |
||
358 | private static function getStashKey( Title $title, $contentHash, User $user ) { |
||
359 | return ObjectCache::getLocalClusterInstance()->makeKey( |
||
360 | 'prepared-edit', |
||
361 | md5( $title->getPrefixedDBkey() ), |
||
362 | // Account for the edit model/text |
||
363 | $contentHash, |
||
364 | // Account for user name related variables like signatures |
||
365 | md5( $user->getId() . "\n" . $user->getName() ) |
||
366 | ); |
||
367 | } |
||
368 | |||
369 | /** |
||
370 | * Build a value to store in memcached based on the PST content and parser output |
||
371 | * |
||
372 | * This makes a simple version of WikiPage::prepareContentForEdit() as stash info |
||
373 | * |
||
374 | * @param Content $pstContent Pre-Save transformed content |
||
375 | * @param ParserOutput $parserOutput |
||
376 | * @param string $timestamp TS_MW |
||
377 | * @param User $user |
||
378 | * @return array (stash info array, TTL in seconds, info code) or (null, 0, info code) |
||
379 | */ |
||
380 | private static function buildStashValue( |
||
381 | Content $pstContent, ParserOutput $parserOutput, $timestamp, User $user |
||
382 | ) { |
||
383 | // If an item is renewed, mind the cache TTL determined by config and parser functions. |
||
384 | // Put an upper limit on the TTL for sanity to avoid extreme template/file staleness. |
||
385 | $since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() ); |
||
386 | $ttl = min( $parserOutput->getCacheExpiry() - $since, self::MAX_CACHE_TTL ); |
||
387 | if ( $ttl <= 0 ) { |
||
388 | return [ null, 0, 'no_ttl' ]; |
||
389 | } |
||
390 | |||
391 | // Only store what is actually needed |
||
392 | $stashInfo = (object)[ |
||
393 | 'pstContent' => $pstContent, |
||
394 | 'output' => $parserOutput, |
||
395 | 'timestamp' => $timestamp, |
||
396 | 'edits' => $user->getEditCount() |
||
397 | ]; |
||
398 | |||
399 | return [ $stashInfo, $ttl, 'ok' ]; |
||
400 | } |
||
401 | |||
402 | public function getAllowedParams() { |
||
403 | return [ |
||
404 | 'title' => [ |
||
405 | ApiBase::PARAM_TYPE => 'string', |
||
406 | ApiBase::PARAM_REQUIRED => true |
||
407 | ], |
||
408 | 'section' => [ |
||
409 | ApiBase::PARAM_TYPE => 'string', |
||
410 | ], |
||
411 | 'sectiontitle' => [ |
||
412 | ApiBase::PARAM_TYPE => 'string' |
||
413 | ], |
||
414 | 'text' => [ |
||
415 | ApiBase::PARAM_TYPE => 'text', |
||
416 | ApiBase::PARAM_DFLT => null |
||
417 | ], |
||
418 | 'stashedtexthash' => [ |
||
419 | ApiBase::PARAM_TYPE => 'string', |
||
420 | ApiBase::PARAM_DFLT => null |
||
421 | ], |
||
422 | 'summary' => [ |
||
423 | ApiBase::PARAM_TYPE => 'string', |
||
424 | ], |
||
425 | 'contentmodel' => [ |
||
426 | ApiBase::PARAM_TYPE => ContentHandler::getContentModels(), |
||
427 | ApiBase::PARAM_REQUIRED => true |
||
428 | ], |
||
429 | 'contentformat' => [ |
||
430 | ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(), |
||
431 | ApiBase::PARAM_REQUIRED => true |
||
432 | ], |
||
433 | 'baserevid' => [ |
||
434 | ApiBase::PARAM_TYPE => 'integer', |
||
435 | ApiBase::PARAM_REQUIRED => true |
||
436 | ] |
||
437 | ]; |
||
438 | } |
||
439 | |||
440 | public function needsToken() { |
||
441 | return 'csrf'; |
||
442 | } |
||
443 | |||
444 | public function mustBePosted() { |
||
445 | return true; |
||
446 | } |
||
447 | |||
448 | public function isWriteMode() { |
||
449 | return true; |
||
450 | } |
||
451 | |||
452 | public function isInternal() { |
||
453 | return true; |
||
454 | } |
||
455 | } |
||
456 |
Let’s assume that you have a directory layout like this:
and let’s assume the following content of
Bar.php
:If both files
OtherDir/Foo.php
andSomeDir/Foo.php
are loaded in the same runtime, you will see a PHP error such as the following:PHP Fatal error: Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php
However, as
OtherDir/Foo.php
does not necessarily have to be loaded and the error is only triggered if it is loaded beforeOtherDir/Bar.php
, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias: