Skip to content

Commit 5973ad3

Browse files
committed
bug #2707 - do not use realpath() per default to avoid conflicts with open_basedir, strong path normalisation
1 parent 3b71efb commit 5973ad3

File tree

2 files changed

+118
-11
lines changed

2 files changed

+118
-11
lines changed

lib/Twig/Loader/Filesystem.php

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,25 @@ class Twig_Loader_Filesystem implements Twig_LoaderInterface, Twig_ExistsLoaderI
2323
protected $cache = array();
2424
protected $errorCache = array();
2525

26+
private $useRealpath = false;
2627
private $rootPath;
2728

2829
/**
2930
* @param string|array $paths A path or an array of paths where to look for templates
3031
* @param string|null $rootPath The root path common to all relative paths (null for getcwd())
3132
*/
32-
public function __construct($paths = array(), $rootPath = null)
33+
public function __construct($paths = array(), $rootPath = null, $useRealpath = false)
3334
{
34-
$this->rootPath = (null === $rootPath ? getcwd() : $rootPath).DIRECTORY_SEPARATOR;
35-
if (false !== $realPath = realpath($rootPath)) {
36-
$this->rootPath = $realPath.DIRECTORY_SEPARATOR;
35+
$this->useRealpath = $useRealpath;
36+
37+
$this->rootPath = (null === $rootPath ? getcwd() : $rootPath);
38+
if ($this->rootPath) {
39+
// realpath() usage for backward compatibility only
40+
if ($useRealpath && false !== ($realPath = realpath($this->rootPath))) {
41+
$this->rootPath = $realPath.DIRECTORY_SEPARATOR;
42+
} else {
43+
$this->rootPath = self::normalizePath($this->rootPath).DIRECTORY_SEPARATOR;
44+
}
3745
}
3846

3947
if ($paths) {
@@ -214,12 +222,12 @@ protected function findTemplate($name)
214222
$path = $this->rootPath.'/'.$path;
215223
}
216224

217-
if (is_file($path.'/'.$shortname)) {
218-
if (false !== $realpath = realpath($path.'/'.$shortname)) {
219-
return $this->cache[$name] = $realpath;
225+
$filename = $path.'/'.$shortname;
226+
if (is_file($filename)) {
227+
if ($this->useRealpath && false !== ($realPath = realpath($filename))) {
228+
$filename = $realPath;
220229
}
221-
222-
return $this->cache[$name] = $path.'/'.$shortname;
230+
return $this->cache[$name] = self::normalizePath($filename);
223231
}
224232
}
225233

@@ -275,6 +283,48 @@ protected function validateName($name)
275283
}
276284
}
277285

286+
/**
287+
* Normalize a path by removing redundant '..', '.' and '/' and thus preventing the
288+
* need of using the realpath() function that may come with some side effects such
289+
* as breaking out open_basedir configuration by attempting to following symlinks
290+
*
291+
* @param string $string
292+
* @param bool $removeTrailingSlash
293+
* @return string
294+
*/
295+
static public function normalizePath($string)
296+
{
297+
// Handle windows gracefully
298+
if (\DIRECTORY_SEPARATOR !== '/') {
299+
$string = \str_replace(\DIRECTORY_SEPARATOR, '/', $string);
300+
}
301+
// Also tests some special cases we can't really do anything with
302+
if (false === \strpos($string, '/') || '/' === $string || '.' === $string || '..' === $string) {
303+
return $string;
304+
}
305+
// This is supposedly invalid, but an empty string is an empty string
306+
if ('' === ($string = \rtrim($string, '/'))) {
307+
return '';
308+
}
309+
310+
$scheme = null;
311+
if (\strpos($string, '://')) {
312+
list($scheme, $string) = \explode('://', $string, 2);
313+
}
314+
315+
// Matches useless '.' repetitions
316+
$string = \preg_replace('@^\./|(/\.)+/|/\.$@', '/', $string);
317+
318+
$count = 0;
319+
do {
320+
// string such as '//' can be generated by the first regex, hence the second
321+
$string = \preg_replace('@[^/]+/+\.\.(/+|$)@', '$2', \preg_replace('@//+@', '/', $string), -1, $count);
322+
} while ($count);
323+
324+
// rtrim() a second time because preg_replace() could leave a trailing '/'
325+
return ($scheme ? ($scheme.'://') : '').\rtrim($string, '/');
326+
}
327+
278328
private function isAbsolutePath($file)
279329
{
280330
return strspn($file, '/\\', 0, 1)

test/Twig/Tests/Loader/FilesystemTest.php

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,16 +211,73 @@ public function testArrayInheritance($templateName)
211211
$this->assertSame('VALID Child', $template->renderBlock('body', array()));
212212
}
213213

214+
public function getPathNormalizationMap()
215+
{
216+
return array(
217+
// Tests with '..'
218+
array('a/b/..', 'a'),
219+
array('https://a/b/../', 'https://a'),
220+
array('/a/b/c/d/../e/f', '/a/b/c/e/f'),
221+
array('a/b/c/../../e/f', 'a/e/f'),
222+
array('ftp://a/../b/../c/../e/f', 'ftp://e/f'),
223+
array('a../b/c../d..e/', 'a../b/c../d..e'),
224+
array('../c/d', '../c/d'),
225+
// With multiple '/'
226+
array('/a/b/////c/d/../e/f', '/a/b/c/e/f'),
227+
array('file:////a/b/c//../..//e/f', 'file:///a/e/f'),
228+
array('////a/../b/../c//../e/f', '/e/f'),
229+
array('a../b//c../d..e/', 'a../b/c../d..e'),
230+
array('../c////d', '../c/d'),
231+
// With dots
232+
array('a/b/./././..', 'a'),
233+
array('a/.b/./../', 'a'),
234+
array('/a/b/.c/d/../e/f', '/a/b/.c/e/f'),
235+
array('.a/./b/c/.././../e./f', '.a/e./f'),
236+
// Special cases
237+
array('/', '/'),
238+
array('.', '.'),
239+
array('..', '..'),
240+
array('./', '.'),
241+
array('../', '..'),
242+
);
243+
}
244+
214245
/**
246+
* @dataProvider getPathNormalizationMap
247+
*/
248+
public function testNormalizePath($path, $expected)
249+
{
250+
$this->assertSame($expected, Twig_Loader_Filesystem::normalizePath($path));
251+
}
252+
253+
public function getUseRealpathConfiguration()
254+
{
255+
return array(array(true), array(false));
256+
}
257+
258+
/**
259+
* @dataProvider getUseRealpathConfiguration
215260
* @requires PHP 5.3
216261
*/
217-
public function testLoadTemplateFromPhar()
262+
public function testLoadTemplateFromPhar($useRealpath)
218263
{
219-
$loader = new Twig_Loader_Filesystem(array());
264+
$loader = new Twig_Loader_Filesystem(array(), null, $useRealpath);
220265
// phar-sample.phar was created with the following script:
221266
// $f = new Phar('phar-test.phar');
222267
// $f->addFromString('hello.twig', 'hello from phar');
223268
$loader->addPath('phar://'.dirname(__FILE__).'/Fixtures/phar/phar-sample.phar');
224269
$this->assertSame('hello from phar', $loader->getSourceContext('hello.twig')->getCode());
225270
}
271+
272+
/**
273+
* @dataProvider getUseRealpathConfiguration
274+
* @requires PHP 5.3
275+
*/
276+
public function testLoadTemplateFromPharNormalization($useRealpath)
277+
{
278+
$loader = new Twig_Loader_Filesystem(array(), null, $useRealpath);
279+
$loader->addPath('phar://'.dirname(__FILE__).'/Fixtures/phar/non-existing-segment/../phar-sample.phar');
280+
$this->assertSame('hello from phar', $loader->getSourceContext('hello.twig')->getCode());
281+
$this->assertSame('hello from phar', $loader->getSourceContext('another-non-existing-segment/../hello.twig')->getCode());
282+
}
226283
}

0 commit comments

Comments
 (0)