MediaWiki master
RawAction.php
Go to the documentation of this file.
1<?php
15namespace MediaWiki\Actions;
16
34use Wikimedia\Timestamp\TimestampFormat as TS;
35
43
44 public function __construct(
45 Article $article,
46 IContextSource $context,
47 private readonly Parser $parser,
48 private readonly PermissionManager $permissionManager,
49 private readonly RevisionLookup $revisionLookup,
50 private readonly RestrictionStore $restrictionStore,
51 private readonly UserFactory $userFactory,
52 ) {
53 parent::__construct( $article, $context );
54 }
55
57 public function getName() {
58 return 'raw';
59 }
60
62 public function requiresWrite() {
63 return false;
64 }
65
67 public function requiresUnblock() {
68 return false;
69 }
70
75 public function onView() {
76 $this->getOutput()->disable();
77 ContentSecurityPolicy::sendRestrictiveHeader();
78 $request = $this->getRequest();
79 $response = $request->response();
80 $config = $this->context->getConfig();
81
82 if ( $this->getOutput()->checkLastModified(
83 $this->getWikiPage()->getTouched()
84 ) ) {
85 // Client cache fresh and headers sent, nothing more to do.
86 return null;
87 }
88
89 $contentType = $this->getContentType();
90
91 $maxage = $request->getInt( 'maxage', $config->get( MainConfigNames::CdnMaxAge ) );
92 $smaxage = $request->getIntOrNull( 'smaxage' );
93 if ( $smaxage === null ) {
94 if (
95 $contentType === 'text/css' ||
96 $contentType === 'application/json' ||
97 $contentType === 'text/javascript'
98 ) {
99 // CSS/JSON/JS raw content has its own CDN max age configuration.
100 // Note: HTMLCacheUpdater::getUrls() includes action=raw for css/json/js
101 // pages, so if using the canonical url, this will get HTCP purges.
102 $smaxage = intval( $config->get( MainConfigNames::ForcedRawSMaxage ) );
103 } else {
104 // No CDN cache for anything else
105 $smaxage = 0;
106 }
107 }
108
109 // Set standard Vary headers so cache varies on cookies and such (T125283)
110 $response->header( $this->getOutput()->getVaryHeader() );
111
112 // Output may contain user-specific data;
113 // vary generated content for open sessions on private wikis
114 $privateCache = !$this->permissionManager->isEveryoneAllowed( 'read' ) &&
115 ( $smaxage === 0 || $request->getSession()->isPersistent() );
116 // Don't accidentally cache cookies if the user is registered (T55032)
117 $privateCache = $privateCache || $this->getUser()->isRegistered();
118 $mode = $privateCache ? 'private' : 'public';
119 $response->header(
120 'Cache-Control: ' . $mode . ', s-maxage=' . $smaxage . ', max-age=' . $maxage
121 );
122
123 // In the event of user JS, don't allow loading a user JS/CSS/Json
124 // subpage that has no registered user associated with, as
125 // someone could register the account and take control of the
126 // JS/CSS/Json page.
127 $title = $this->getTitle();
128 if ( $title->isUserConfigPage() && $contentType !== 'text/x-wiki' ) {
129 // not using getRootText() as we want this to work
130 // even if subpages are disabled.
131 $rootPage = strtok( $title->getText(), '/' );
132 $userFromTitle = $this->userFactory->newFromName( $rootPage, UserRigorOptions::RIGOR_USABLE );
133 if ( !$userFromTitle || !$userFromTitle->isRegistered() ) {
134 $elevated = $this->getAuthority()->isAllowed( 'editinterface' );
135 $elevatedText = $elevated ? 'by elevated ' : '';
136 $log = LoggerFactory::getInstance( "security" );
137 $log->warning(
138 "Unsafe JS/CSS/Json {$elevatedText}load - {user} loaded {title} with {ctype}",
139 [
140 'user' => $this->getUser()->getName(),
141 'title' => $title->getPrefixedDBkey(),
142 'ctype' => $contentType,
143 'elevated' => $elevated
144 ]
145 );
146 throw new HttpError( 403, wfMessage( 'unregistered-user-config' ) );
147 }
148 }
149
150 // Don't allow loading non-protected pages as javascript.
151 // In the future, we may further restrict this to only CONTENT_MODEL_JAVASCRIPT
152 // in NS_MEDIAWIKI or NS_USER, as well as including other config types,
153 // but for now be more permissive. Allowing protected pages outside
154 // NS_USER and NS_MEDIAWIKI in particular should be considered a temporary
155 // allowance.
156 if (
157 $contentType === 'text/javascript' &&
158 !$title->isUserJsConfigPage() &&
159 !$title->inNamespace( NS_MEDIAWIKI )
160 ) {
161 $pageRestrictions = $this->restrictionStore->getRestrictions( $title, 'edit' );
162 if ( !in_array( 'sysop', $pageRestrictions ) &&
163 !in_array( 'editprotected', $pageRestrictions )
164 ) {
165 $log = LoggerFactory::getInstance( "security" );
166 $log->info( "Blocked loading unprotected JS {title} for {user}",
167 [
168 'user' => $this->getUser()->getName(),
169 'title' => $title->getPrefixedDBkey(),
170 ]
171 );
172 throw new HttpError( 403, wfMessage( 'unprotected-js' ) );
173 }
174 }
175
176 // Content-Type: text/javascript should only work when the following are true:
177 // page is in User subpage or Mediawiki namespace
178 // page title ends in .js or .vue
179 // page content type is CONTENT_MODEL_JAVASCRIPT or CONTENT_MODEL_VUE
180 // currently only logging to determine how many pages would be impacted by this change
181 // checking only for pages that exist
182 if ( $contentType === 'text/javascript' && $title->exists() ) {
183 if ( !( $title->isSiteJsConfigPage() || $title->isUserJsConfigPage() ) ) {
184 $redirectTarget = MediaWikiServices::getInstance()
185 ->getRedirectLookup()
186 ->getRedirectTarget( $title );
187 $redirectTitle = $redirectTarget ? Title::newFromLinkTarget( $redirectTarget ) : null;
188 $isRedirectToJsConfigPage = $redirectTitle &&
189 ( $redirectTitle->isSiteJsConfigPage() || $redirectTitle->isUserJsConfigPage() );
190
191 $log = LoggerFactory::getInstance( "security" );
192 if ( $isRedirectToJsConfigPage ) {
193 $log->info(
194 "Did not block loading JS redirect {title} to {redirectTarget} "
195 . "for {user} with more restrictions",
196 [
197 'user' => $this->getUser()->getName(),
198 'title' => $title->getPrefixedDBkey(),
199 'redirectTarget' => $redirectTitle->getPrefixedDBkey(),
200 ]
201 );
202 } else {
203 $log->info( "Did not block loading unprotected JS {title} for {user} with more restrictions",
204 [
205 'user' => $this->getUser()->getName(),
206 'title' => $title->getPrefixedDBkey(),
207 ]
208 );
209 }
210 }
211 }
212
213 $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' );
214
215 $text = $this->getRawText();
216
217 // Don't return a 404 response for CSS or JavaScript;
218 // 404s aren't generally cached, and it would create
219 // extra hits when user CSS/JS are on and the user doesn't
220 // have the pages.
221 if ( $text === false && $contentType === 'text/x-wiki' ) {
222 $response->statusHeader( 404 );
223 }
224
225 if ( !$this->getHookRunner()->onRawPageViewBeforeOutput( $this, $text ) ) {
226 wfDebug( __METHOD__ . ": RawPageViewBeforeOutput hook broke raw page output." );
227 }
228
229 echo $text;
230
231 return null;
232 }
233
240 public function getRawText() {
241 $text = false;
242 $title = $this->getTitle();
243 $request = $this->getRequest();
244
245 // Get it from the DB
246 $rev = $this->revisionLookup->getRevisionByTitle( $title, $this->getOldId() );
247 if ( $rev ) {
248 $lastMod = wfTimestamp( TS::RFC2822, $rev->getTimestamp() );
249 $request->response()->header( "Last-modified: $lastMod" );
250
251 // Public-only due to cache headers
252 // Fetch specific slot if defined
253 $slot = $this->getRequest()->getText( 'slot' );
254 if ( $slot ) {
255 if ( $rev->hasSlot( $slot ) ) {
256 $content = $rev->getContent( $slot );
257 } else {
258 $content = null;
259 }
260 } else {
261 $content = $rev->getContent( SlotRecord::MAIN );
262 }
263
264 if ( $content === null ) {
265 // revision or slot was not found (or suppressed)
266 } elseif ( !$content instanceof TextContent && !method_exists( $content, 'getText' ) ) {
267 // non-text content
269 415,
270 "Unsupported Media Type", "The requested page uses the content model `"
271 . $content->getModel() . "` which is not supported via this interface."
272 );
273 die();
274 } else {
275 // want a section?
276 $section = $request->getIntOrNull( 'section' );
277 if ( $section !== null ) {
278 $content = $content->getSection( $section );
279 }
280
281 if ( $content !== null && $content !== false ) {
282 // section found (and section supported, e.g. not for JS, JSON, and CSS)
283 $text = $content->getText();
284 }
285 }
286 }
287
288 if ( $text !== false && $text !== '' && $request->getRawVal( 'templates' ) === 'expand' ) {
289 $text = $this->parser->preprocess(
290 $text,
291 $title,
293 );
294 }
295
296 return $text;
297 }
298
304 public function getOldId() {
305 $oldId = $this->getRequest()->getInt( 'oldid' );
306 $rl = $this->revisionLookup;
307 switch ( $this->getRequest()->getText( 'direction' ) ) {
308 case 'next':
309 # output next revision, or nothing if there isn't one
310 $nextRev = null;
311 if ( $oldId ) {
312 $oldRev = $rl->getRevisionById( $oldId );
313 if ( $oldRev ) {
314 $nextRev = $rl->getNextRevision( $oldRev );
315 }
316 }
317 $oldId = $nextRev ? $nextRev->getId() : -1;
318 break;
319 case 'prev':
320 # output previous revision, or nothing if there isn't one
321 $prevRev = null;
322 if ( !$oldId ) {
323 # get the latest revision so we can get the penultimate one
324 $oldId = $this->getWikiPage()->getLatest();
325 }
326 $oldRev = $rl->getRevisionById( $oldId );
327 if ( $oldRev ) {
328 $prevRev = $rl->getPreviousRevision( $oldRev );
329 }
330 $oldId = $prevRev ? $prevRev->getId() : -1;
331 break;
332 case 'cur':
333 $oldId = 0;
334 break;
335 }
336
337 // @phan-suppress-next-line PhanTypeMismatchReturnNullable RevisionRecord::getId does not return null here
338 return $oldId;
339 }
340
346 public function getContentType() {
347 // Optimisation: Avoid slow getVal(), this isn't user-generated content.
348 $ctype = $this->getRequest()->getRawVal( 'ctype' );
349
350 if ( $ctype == '' ) {
351 // Legacy compatibility
352 $gen = $this->getRequest()->getRawVal( 'gen' );
353 if ( $gen == 'js' ) {
354 $ctype = 'text/javascript';
355 } elseif ( $gen == 'css' ) {
356 $ctype = 'text/css';
357 }
358 }
359
360 static $allowedCTypes = [
361 'text/x-wiki',
362 'text/javascript',
363 'text/css',
364 // FIXME: Should we still allow Zope editing? External editing feature was dropped
365 'application/x-zope-edit',
366 'application/json'
367 ];
368 if ( $ctype == '' || !in_array( $ctype, $allowedCTypes ) ) {
369 $ctype = 'text/x-wiki';
370 }
371
372 return $ctype;
373 }
374}
375
377class_alias( RawAction::class, 'RawAction' );
const NS_MEDIAWIKI
Definition Defines.php:59
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfHttpError( $code, $label, $desc)
Provide a simple HTTP error.
wfTimestamp( $outputtype=TS::UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
getContext()
Get the IContextSource in use here.
Definition Action.php:102
getWikiPage()
Get a WikiPage object.
Definition Action.php:171
getUser()
Shortcut to get the User being used for this instance.
Definition Action.php:132
getTitle()
Shortcut to get the Title object from the page.
Definition Action.php:191
getRequest()
Get the WebRequest being used for this instance.
Definition Action.php:112
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:122
getAuthority()
Shortcut to get the Authority executing this instance.
Definition Action.php:142
An action which just does something, without showing a form first.
A simple method to retrieve the plain source of an article, using "action=raw" in the GET request str...
Definition RawAction.php:42
requiresUnblock()
Whether this action can still be executed by a blocked user.Implementations of this methods must alwa...
Definition RawAction.php:67
getOldId()
Get the ID of the revision that should be used to get the text.
getName()
Return the name of the action this object responds to.1.17string Lowercase name
Definition RawAction.php:57
getContentType()
Get the content type to be used for the response.
getRawText()
Get the text that should be returned, or false if the page or revision was not found.
__construct(Article $article, IContextSource $context, private readonly Parser $parser, private readonly PermissionManager $permissionManager, private readonly RevisionLookup $revisionLookup, private readonly RestrictionStore $restrictionStore, private readonly UserFactory $userFactory,)
Definition RawAction.php:44
requiresWrite()
Indicates whether this action page write access to the wiki.Subclasses must override this method to r...
Definition RawAction.php:62
Content object implementation for representing flat text.
Show an error that looks like an HTTP server error.
Definition HttpError.php:23
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const CdnMaxAge
Name constant for the CdnMaxAge setting, for use with Config::get()
const ForcedRawSMaxage
Name constant for the ForcedRawSMaxage setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Legacy class representing an editable page and handling UI for some page actions.
Definition Article.php:66
Set options of the Parser.
static newFromContext(IContextSource $context)
Get a ParserOptions object from a IContextSource object.
PHP Parser - Processes wiki markup (which uses a more user-friendly syntax, such as "[[link]]" for ma...
Definition Parser.php:138
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Handle sending Content-Security-Policy headers.
Value object representing a content slot associated with a page revision.
Represents a title within MediaWiki.
Definition Title.php:69
Create User objects.
Interface for objects which can provide a MediaWiki context on request.
Service for looking up page revisions.
Shared interface for rigor levels when dealing with User methods.