1<?php
2/**
3 * Display File Dokuwiki Plugin (Syntax Component)
4 *
5 * Description: The Display File Plugin displays the content of a specified file on the local system using a displayfile element
6 *
7 * Syntax: <displayfile lang target />
8 *         lang   (Required): The language of the content file. This is used by Dokuwiki's built-in syntax highlighting GeSHi
9 *                            library. To disable syntax highlighting, specify a dask (-) character for the lang value. The
10 *                            supported lang values are the same as those provided by Dokuwiki's <code> and <file> markup and
11 *                            can be found on the Dokuwiki syntax page: https://www.dokuwiki.org/wiki:syntax#syntax_highlighting
12 *         target (Required): The last part of a file path to the desired file on the local file system. This will be appended to
13 *                            the value of the plugin's root_path configuration option. The target value can be enclosed in single
14 *                            or double quotes (' or "). The target path part must be enclosed in quotes if it contains spaces.
15 *
16 * @license    The MIT License (https://opensource.org/licenses/MIT)
17 * @author     Jay Jeckel <jeckelmail@gmail.com>
18 *
19 * Copyright (c) 2016 Jay Jeckel
20 * Licensed under the MIT license: https://opensource.org/licenses/MIT
21 * Permission is granted to use, copy, modify, and distribute the work.
22 * Full license information available in the project LICENSE file.
23 */
24
25if(!defined('DOKU_INC')) { die(); }
26
27class syntax_plugin_displayfile extends DokuWiki_Syntax_Plugin
28{
29    const PATTERN = '<displayfile\s+[a-z0-9_\-]+?\s*.*?\s*\/>';
30
31    function getInfo() { return confToHash(dirname(__FILE__) . '/plugin.info.txt'); }
32
33    function getType() { return 'substition'; }
34
35    function getPType() { return 'block'; }
36
37    function getSort() { return 5; }// Must come before the <code>/<file> patterns are handled at 200/210.
38
39    function connectTo($mode)
40    {
41        $this->Lexer->addSpecialPattern(self::PATTERN, $mode, 'plugin_displayfile');
42        $pattern = '<<' . 'display' . '\s' . 'file' . '\s' . '[a-z0-9_\-]+?' . '\s*' . '.*?' . '>>';
43        $this->Lexer->addSpecialPattern($pattern, $mode, 'plugin_displayfile');
44    }
45
46    function handle($match, $state, $pos, Doku_Handler $handler)
47    {
48        $match = $match[1] == '<' ? substr($match, 15, -2) : substr($match, 13, -3);
49        list($language, $target) = explode(' ', $match, 2);
50
51        $target = trim($target, " \t\n\r\0\x0B\"'");
52        $target = str_replace("\\", '/', $target);
53        $target = ltrim($target, '/');
54
55        $target_title = false;
56        $title = ($target_title ? $target : basename($target));
57
58        $text = 'CONTENT NOT SET';
59        $error = $this->_validate($target, $title, $text);
60        return $error === false ? array($text, $language, $title) : array("ERROR: {$error}", null, null);
61        //$output = $error === false ? "$language $title>$text" : "->ERROR: $error";
62        //$handler->file($output, DOKU_LEXER_UNMATCHED, $pos);
63        //return null;
64    }
65
66    function render($format, Doku_Renderer $renderer, $data)
67    {
68        if ($format == 'xhtml')
69        {
70            list($text, $language, $title) = $data;
71            if (!$this->_isPhpFixRequired($text, $language))
72            { $renderer->file($text, $language, $title); }
73            else
74            {
75                $this->_displayHeader($renderer, $title);
76                $this->_displayBody($renderer, $text, $language);
77                $renderer->doc .= '</dd></dl>'.DOKU_LF;
78            }
79            return true;
80        }
81        return false;
82    }
83
84    // Returns error message or false; if false, then $content holds the file contents.
85    function _validate($target, $title, &$content)
86    {
87        $root = $this->getConf('root_path');
88        $root = str_replace("\\", '/', $root);
89        $root = rtrim($root, '/');
90
91        // First, validate that the root path is set to something.
92        if (!isset($root) || $root === '')
93        { return $this->getLang('error_root_empty'); }
94
95        $real_root = realpath($root);
96
97        // Second, check that the root path exists and is a directory.
98        if ($real_root === false || !is_dir($real_root))
99        { return $this->getLang('error_root_invalid'); }
100
101        // We now know that root exists.
102
103        // Third, defend against simple traversal attacks in the target.
104        else if (strpos($target, '../') !== false)
105        { return sprintf($this->getLang('error_traversal_token'), $title); }
106
107        $ext = pathinfo($target , PATHINFO_EXTENSION);
108
109        // Fourth, validate deny of extension.
110        $deny_extensions = explode(' ', $this->getConf('deny_extensions'));//array('sh');
111        if (count($deny_extensions) > 0 && in_array($ext, $deny_extensions))
112        { return sprintf($this->getLang('error_extension_deny'), $ext); }
113
114        // Fifth, validate deny of extension.
115        $allow_extensions = explode(' ', $this->getConf('allow_extensions'));//array('txt', 'php', 'js', 'css');
116        if (count($allow_extensions) > 0 && !in_array($ext, $allow_extensions))
117        { return sprintf($this->getLang('error_extension_allow'), $ext); }
118
119        $real_path = realpath($real_root . "/" . $target);
120
121        // Sixth, catch the file trying to read itself.
122        // Behavior undefined if this file doesn't exist.
123        if (realpath(__FILE__) == $real_path)
124        { return sprintf($this->getLang('error_self'), $title); }
125
126        // !!ATTENTION!! Security must be considered in the following sections.
127        //
128        // The root path is assumed safe as it is provided by the admin,
129        // but the target path stub is user-provided input and must be
130        // treated as dangerous by default. Specifically, path traversal
131        // attacks need to be mitigated.
132        //
133        // The main threat is use of path traversal shortcuts, such as the
134        // '.', '..', and '~' tokens, to cause the resolved path to point at
135        // files and directories outside the expected root path. Thankfully,
136        // it is fairly easy to detect these attacks by normalizing and expanding
137        // both the root and the path using realpath(). If the real path doesn't
138        // literally begin with the real root, then you know you have a traversal.
139        // Good, then that's done, right? Mostly, but not quite.
140        //
141        // The quirk is that realpath() only works for files that exist. If the
142        // file doesn't exist, then you don't have a real path to compare to the
143        // real root. Where the issue appears is when the developer follows their
144        // natural instinct and tries to help the user by providing more useful
145        // error messages; first check if the file doesn't exist, say as much; then
146        // when we know the file exists, another message to say it is invalid if it
147        // traverses outside the root.
148        //
149        // And there it is, you've opened a hole into the underlying system. What
150        // we've done is give an attacker the ability to check if any arbitrary File
151        // does or doesn't exist on whatever system is running the plugin. I know
152        // that doesn't seem like much, but by checking the existence of files an
153        // attacker can determine what OS the system is running, down to the specific
154        // version of the OS, and that could inform more tailored attacks.
155        //
156        // Fortunately, there is a simple fix for this: deny the urge to be helpful.
157        // Don't provide separate messages for the files that don't exist and files
158        // that exist but have traversed outside the root path. This way the only
159        // thing an attacker knows is that they can't access a file, nothing more, and
160        // that is all they need to know. For good measure, all invalid paths should
161        // return this same message to avoid any kind of proping of file information
162        // through error messages.
163
164        // Seventh, ensure the path exists.
165        else if ($real_path === false) { return sprintf($this->getLang('error_access'), $title); }
166
167        // We know that root and path exist.
168
169        // Eighth, the final wall against path traversal outside the root.
170        // Simple begins-with comparison, it's simple but effective.
171        else if (strpos($real_path, $real_root) !== 0) { return sprintf($this->getLang('error_access'), $title); }
172
173        // Ninth, ensure the file is a file and readable, and do any other good-measure checks.
174        else if (is_dir($real_path)) { return sprintf($this->getLang('error_access'), $title); }
175        else if (!is_readable($real_path)) { return sprintf($this->getLang('error_access'), $title); }
176
177        // Tenth, attempt to read the contents and return error if failed.
178        $result = file_get_contents($real_path);
179        if ($result === false)
180        {
181            // This represents an actual error outside our control. Since the user should
182            // have access to this content, it's ok to have a distinct error message.
183            //return "The file '$title' could not be read.";
184            return sprintf($this->getLang('error_read'), $title);
185        }
186
187        // Finally, set the out variable to the result of the read and return false.
188        $content = $result;
189        return false;
190    }
191
192    // There is a bug in GeShi, the syntax highlighting engine underlying Dokuwiki.
193    // If it is passed content that starts with a < character, is told to highlight as
194    // php, and contains a multiline comment of more than 610 characters, then the page
195    // will crash. If any one of those isn't met, then the bug isn't raised.
196    function _isPhpFixCandidate($text, $language)
197    { return $text[0] == '<' && ($language == 'php' || $language == 'php-brief'); }
198
199    function _isPhpFixRequired($text, $language)
200    {
201        if ($this->_isPhpFixCandidate($text, $language))
202        {
203            $result = preg_match_all('/\/\*.{610,}?\*\//s', $text);
204            return $result === false || $result > 0;
205        }
206        return false;
207    }
208
209    function _getCodeBlockCount(Doku_Renderer $renderer)
210    {
211        $reflection = new ReflectionClass($renderer);
212        $property = $reflection->getProperty('_codeblock');
213        $property->setAccessible(true);
214        return $property->getValue($renderer);
215    }
216
217    function _incCodeBlockCount(Doku_Renderer $renderer)
218    {
219        $reflection = new ReflectionClass($renderer);
220        $property = $reflection->getProperty('_codeblock');
221        $property->setAccessible(true);
222        $codeblock = $property->getValue($renderer);
223        $property->setValue($renderer, $codeblock + 1);
224        return $property->getValue($renderer);
225    }
226
227    function _displayHeader(Doku_Renderer $renderer, $title)
228    {
229        global $ID;
230        global $lang;
231        global $INPUT;
232
233        list($ext) = mimetype($title, false);
234        $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext);
235        $class = 'mediafile mf_' . $class;
236
237        $offset = 0;
238        if ($INPUT->has('codeblockOffset'))
239        { $offset = $INPUT->str('codeblockOffset'); }
240
241        $renderer->doc .= '<dl class="file">'.DOKU_LF;
242        $renderer->doc .= '<dt><a href="'
243            . exportlink($ID, 'code', array('codeblock' => $offset + $this->_getCodeBlockCount($renderer)))
244            . '" title="' . $lang['download'] . '" class="' . $class . '">';
245        $renderer->doc .= hsc($title);
246        $renderer->doc .= '</a></dt>'.DOKU_LF.'<dd>';
247    }
248
249    function _displayBody(Doku_Renderer $renderer, $text, $language = null, $options = null)
250    {
251        $language = preg_replace(PREG_PATTERN_VALID_LANGUAGE, '', $language);
252        $language = preg_replace(PREG_PATTERN_VALID_LANGUAGE, '', $language);
253
254        //if($text[0] == "\n") { $text = substr($text, 1); }
255        //if(substr($text, -1) == "\n") { $text = substr($text, 0, -1); }
256
257        if(empty($language))
258        { $renderer->doc .= '<pre class="file">' .$renderer->_xmlEntities($text) . '</pre>' . DOKU_LF; }
259        else
260        {
261            $renderer->doc .= "<pre class=\"code file {$language}\">" . DOKU_LF;
262            // This method is only called when this is true.
263            //if ($this->_isPhpFixRequired($text, $language))
264            {
265                // To avoid the GeShi error, we strip the first line
266                // and process it separately from the rest.
267                $index = strpos($text, "\n");
268                $line = substr($text, 0, $index);
269                $renderer->doc .= p_xhtml_cached_geshi($line, $language, '', $options) . DOKU_LF;
270                $text = substr($text, $index + 1);
271            }
272            $renderer->doc .= p_xhtml_cached_geshi($text, $language, '', $options) . DOKU_LF;
273            $renderer->doc .= '</pre>' . DOKU_LF;
274        }
275
276        //$renderer->_codeblock++;
277        $this->_incCodeBlockCount($renderer);
278    }
279}
280
281//Setup VIM: ex: et ts=4 enc=utf-8 :
282?>