RawAction.php 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. <?php
  2. /**
  3. * Raw page text accessor
  4. *
  5. * Copyright © 2004 Gabriel Wicke <wicke@wikidev.net>
  6. * http://wikidev.net/
  7. *
  8. * Based on HistoryAction and SpecialExport
  9. *
  10. * This program is free software; you can redistribute it and/or modify
  11. * it under the terms of the GNU General Public License as published by
  12. * the Free Software Foundation; either version 2 of the License, or
  13. * (at your option) any later version.
  14. *
  15. * This program is distributed in the hope that it will be useful,
  16. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. * GNU General Public License for more details.
  19. *
  20. * You should have received a copy of the GNU General Public License along
  21. * with this program; if not, write to the Free Software Foundation, Inc.,
  22. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  23. * http://www.gnu.org/copyleft/gpl.html
  24. *
  25. * @author Gabriel Wicke <wicke@wikidev.net>
  26. * @file
  27. */
  28. use MediaWiki\Logger\LoggerFactory;
  29. use MediaWiki\MediaWikiServices;
  30. /**
  31. * A simple method to retrieve the plain source of an article,
  32. * using "action=raw" in the GET request string.
  33. *
  34. * @ingroup Actions
  35. */
  36. class RawAction extends FormlessAction {
  37. public function getName() {
  38. return 'raw';
  39. }
  40. public function requiresWrite() {
  41. return false;
  42. }
  43. public function requiresUnblock() {
  44. return false;
  45. }
  46. /**
  47. * @suppress SecurityCheck-XSS Non html mime type
  48. * @return string|null
  49. */
  50. function onView() {
  51. $this->getOutput()->disable();
  52. $request = $this->getRequest();
  53. $response = $request->response();
  54. $config = $this->context->getConfig();
  55. if ( !$request->checkUrlExtension() ) {
  56. return null;
  57. }
  58. if ( $this->getOutput()->checkLastModified( $this->page->getTouched() ) ) {
  59. return null; // Client cache fresh and headers sent, nothing more to do.
  60. }
  61. $contentType = $this->getContentType();
  62. $maxage = $request->getInt( 'maxage', $config->get( 'CdnMaxAge' ) );
  63. $smaxage = $request->getIntOrNull( 'smaxage' );
  64. if ( $smaxage === null ) {
  65. if (
  66. $contentType == 'text/css' ||
  67. $contentType == 'application/json' ||
  68. $contentType == 'text/javascript'
  69. ) {
  70. // CSS/JSON/JS raw content has its own CDN max age configuration.
  71. // Note: Title::getCdnUrls() includes action=raw for css/json/js
  72. // pages, so if using the canonical url, this will get HTCP purges.
  73. $smaxage = intval( $config->get( 'ForcedRawSMaxage' ) );
  74. } else {
  75. // No CDN cache for anything else
  76. $smaxage = 0;
  77. }
  78. }
  79. // Set standard Vary headers so cache varies on cookies and such (T125283)
  80. $response->header( $this->getOutput()->getVaryHeader() );
  81. // Output may contain user-specific data;
  82. // vary generated content for open sessions on private wikis
  83. $privateCache = !User::isEveryoneAllowed( 'read' ) &&
  84. ( $smaxage == 0 || MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() );
  85. // Don't accidentally cache cookies if user is logged in (T55032)
  86. $privateCache = $privateCache || $this->getUser()->isLoggedIn();
  87. $mode = $privateCache ? 'private' : 'public';
  88. $response->header(
  89. 'Cache-Control: ' . $mode . ', s-maxage=' . $smaxage . ', max-age=' . $maxage
  90. );
  91. // In the event of user JS, don't allow loading a user JS/CSS/Json
  92. // subpage that has no registered user associated with, as
  93. // someone could register the account and take control of the
  94. // JS/CSS/Json page.
  95. $title = $this->getTitle();
  96. if ( $title->isUserConfigPage() && $contentType !== 'text/x-wiki' ) {
  97. // not using getRootText() as we want this to work
  98. // even if subpages are disabled.
  99. $rootPage = strtok( $title->getText(), '/' );
  100. $userFromTitle = User::newFromName( $rootPage, 'usable' );
  101. if ( !$userFromTitle || $userFromTitle->getId() === 0 ) {
  102. $elevated = MediaWikiServices::getInstance()->getPermissionManager()
  103. ->userHasRight( $this->getUser(), 'editinterface' );
  104. $elevatedText = $elevated ? 'by elevated ' : '';
  105. $log = LoggerFactory::getInstance( "security" );
  106. $log->warning(
  107. "Unsafe JS/CSS/Json {$elevatedText}load - {user} loaded {title} with {ctype}",
  108. [
  109. 'user' => $this->getUser()->getName(),
  110. 'title' => $title->getPrefixedDBkey(),
  111. 'ctype' => $contentType,
  112. 'elevated' => $elevated
  113. ]
  114. );
  115. $msg = wfMessage( 'unregistered-user-config' );
  116. throw new HttpError( 403, $msg );
  117. }
  118. }
  119. // Don't allow loading non-protected pages as javascript.
  120. // In future we may further restrict this to only CONTENT_MODEL_JAVASCRIPT
  121. // in NS_MEDIAWIKI or NS_USER, as well as including other config types,
  122. // but for now be more permissive. Allowing protected pages outside of
  123. // NS_USER and NS_MEDIAWIKI in particular should be considered a temporary
  124. // allowance.
  125. if (
  126. $contentType === 'text/javascript' &&
  127. !$title->isUserJsConfigPage() &&
  128. !$title->inNamespace( NS_MEDIAWIKI ) &&
  129. !in_array( 'sysop', $title->getRestrictions( 'edit' ) ) &&
  130. !in_array( 'editprotected', $title->getRestrictions( 'edit' ) )
  131. ) {
  132. $log = LoggerFactory::getInstance( "security" );
  133. $log->info( "Blocked loading unprotected JS {title} for {user}",
  134. [
  135. 'user' => $this->getUser()->getName(),
  136. 'title' => $title->getPrefixedDBkey(),
  137. ]
  138. );
  139. throw new HttpError( 403, wfMessage( 'unprotected-js' ) );
  140. }
  141. $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' );
  142. $text = $this->getRawText();
  143. // Don't return a 404 response for CSS or JavaScript;
  144. // 404s aren't generally cached and it would create
  145. // extra hits when user CSS/JS are on and the user doesn't
  146. // have the pages.
  147. if ( $text === false && $contentType == 'text/x-wiki' ) {
  148. $response->statusHeader( 404 );
  149. }
  150. // Avoid PHP 7.1 warning of passing $this by reference
  151. $rawAction = $this;
  152. if ( !Hooks::run( 'RawPageViewBeforeOutput', [ &$rawAction, &$text ] ) ) {
  153. wfDebug( __METHOD__ . ": RawPageViewBeforeOutput hook broke raw page output.\n" );
  154. }
  155. echo $text;
  156. return null;
  157. }
  158. /**
  159. * Get the text that should be returned, or false if the page or revision
  160. * was not found.
  161. *
  162. * @return string|bool
  163. */
  164. public function getRawText() {
  165. $text = false;
  166. $title = $this->getTitle();
  167. $request = $this->getRequest();
  168. // Get it from the DB
  169. $rev = Revision::newFromTitle( $title, $this->getOldId() );
  170. if ( $rev ) {
  171. $lastmod = wfTimestamp( TS_RFC2822, $rev->getTimestamp() );
  172. $request->response()->header( "Last-modified: $lastmod" );
  173. // Public-only due to cache headers
  174. $content = $rev->getContent();
  175. if ( $content === null ) {
  176. // revision not found (or suppressed)
  177. $text = false;
  178. } elseif ( !$content instanceof TextContent ) {
  179. // non-text content
  180. wfHttpError( 415, "Unsupported Media Type", "The requested page uses the content model `"
  181. . $content->getModel() . "` which is not supported via this interface." );
  182. die();
  183. } else {
  184. // want a section?
  185. $section = $request->getIntOrNull( 'section' );
  186. if ( $section !== null ) {
  187. $content = $content->getSection( $section );
  188. }
  189. if ( $content === null || $content === false ) {
  190. // section not found (or section not supported, e.g. for JS, JSON, and CSS)
  191. $text = false;
  192. } else {
  193. $text = $content->getText();
  194. }
  195. }
  196. }
  197. if ( $text !== false && $text !== '' && $request->getRawVal( 'templates' ) === 'expand' ) {
  198. $text = MediaWikiServices::getInstance()->getParser()->preprocess(
  199. $text,
  200. $title,
  201. ParserOptions::newFromContext( $this->getContext() )
  202. );
  203. }
  204. return $text;
  205. }
  206. /**
  207. * Get the ID of the revision that should used to get the text.
  208. *
  209. * @return int
  210. */
  211. public function getOldId() {
  212. $oldid = $this->getRequest()->getInt( 'oldid' );
  213. $rl = MediaWikiServices::getInstance()->getRevisionLookup();
  214. switch ( $this->getRequest()->getText( 'direction' ) ) {
  215. case 'next':
  216. # output next revision, or nothing if there isn't one
  217. $nextRev = null;
  218. if ( $oldid ) {
  219. $oldRev = $rl->getRevisionById( $oldid );
  220. if ( $oldRev ) {
  221. $nextRev = $rl->getNextRevision( $oldRev );
  222. }
  223. }
  224. $oldid = $nextRev ? $nextRev->getId() : -1;
  225. break;
  226. case 'prev':
  227. # output previous revision, or nothing if there isn't one
  228. $prevRev = null;
  229. if ( !$oldid ) {
  230. # get the current revision so we can get the penultimate one
  231. $oldid = $this->page->getLatest();
  232. }
  233. $oldRev = $rl->getRevisionById( $oldid );
  234. if ( $oldRev ) {
  235. $prevRev = $rl->getPreviousRevision( $oldRev );
  236. }
  237. $oldid = $prevRev ? $prevRev->getId() : -1;
  238. break;
  239. case 'cur':
  240. $oldid = 0;
  241. break;
  242. }
  243. return $oldid;
  244. }
  245. /**
  246. * Get the content type to use for the response
  247. *
  248. * @return string
  249. */
  250. public function getContentType() {
  251. // Optimisation: Avoid slow getVal(), this isn't user-generated content.
  252. $ctype = $this->getRequest()->getRawVal( 'ctype' );
  253. if ( $ctype == '' ) {
  254. // Legacy compatibilty
  255. $gen = $this->getRequest()->getRawVal( 'gen' );
  256. if ( $gen == 'js' ) {
  257. $ctype = 'text/javascript';
  258. } elseif ( $gen == 'css' ) {
  259. $ctype = 'text/css';
  260. }
  261. }
  262. $allowedCTypes = [
  263. 'text/x-wiki',
  264. 'text/javascript',
  265. 'text/css',
  266. // FIXME: Should we still allow Zope editing? External editing feature was dropped
  267. 'application/x-zope-edit',
  268. 'application/json'
  269. ];
  270. if ( $ctype == '' || !in_array( $ctype, $allowedCTypes ) ) {
  271. $ctype = 'text/x-wiki';
  272. }
  273. return $ctype;
  274. }
  275. }