Unverified Commit d8ec05c3 authored by larowlan's avatar larowlan

Issue #2620304 by kim.pepper, andypost, phenaproxima, mcdruid, alexpott,...

Issue #2620304 by kim.pepper, andypost, phenaproxima, mcdruid, alexpott, jibran, starshaped, kostyashupenko, nickolaj, Berdir, larowlan, catch, joachim, Mile23: htaccess functions should be a service
parent 8d029c49
......@@ -427,6 +427,9 @@ services:
logger.channel.form:
parent: logger.channel_base
arguments: ['form']
logger.channel.security:
parent: logger.channel_base
arguments: ['security']
logger.log_message_parser:
class: Drupal\Core\Logger\LogMessageParser
......@@ -1648,6 +1651,9 @@ services:
- { name: twig.loader, priority: -100 }
element_info:
alias: plugin.manager.element_info
file.htaccess_writer:
class: Drupal\Core\File\HtaccessWriter
arguments: ['@logger.channel.security', '@stream_wrapper_manager']
file.mime_type.guesser:
class: Drupal\Core\File\MimeType\MimeTypeGuesser
arguments: ['@stream_wrapper_manager']
......
......@@ -13,8 +13,6 @@
use Drupal\Core\File\Exception\FileWriteException;
use Drupal\Core\File\FileSystem;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\StreamWrapper\PrivateStream;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
/**
......@@ -326,22 +324,15 @@ function file_prepare_directory(&$directory, $options = FileSystemInterface::MOD
/**
* Creates a .htaccess file in each Drupal files directory if it is missing.
*
* @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use
* \Drupal\Core\File\HtaccessWriterInterface::ensure() instead.
*
* @see https://www.drupal.org/node/2940126
*/
function file_ensure_htaccess() {
file_save_htaccess('public://', FALSE);
$private_path = PrivateStream::basePath();
if (!empty($private_path)) {
file_save_htaccess('private://', TRUE);
}
file_save_htaccess('temporary://', TRUE);
$staging = Settings::get('config_sync_directory', FALSE);
if ($staging) {
// Note that we log an error here if we can't write the .htaccess file. This
// can occur if the staging directory is read-only. If it is then it is the
// user's responsibility to create the .htaccess file.
file_save_htaccess($staging, TRUE);
}
@trigger_error("file_ensure_htaccess() is deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\HtaccessWriter::ensure() instead. See https://www.drupal.org/node/2940126", E_USER_DEPRECATED);
\Drupal::service('file.htaccess_writer')->ensure();
}
/**
......@@ -355,25 +346,18 @@ function file_ensure_htaccess() {
* @param bool $force_overwrite
* (Optional) Set to TRUE to attempt to overwrite the existing .htaccess file
* if one is already present. Defaults to FALSE.
*
* @return bool
* TRUE when file exists or created successfully, FALSE otherwise.
*
* @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use
* \Drupal\Component\FileSecurity\FileSecurity::writeHtaccess() instead.
*
* @see https://www.drupal.org/node/2940126
*/
function file_save_htaccess($directory, $private = TRUE, $force_overwrite = FALSE) {
/** @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager */
$stream_wrapper_manager = \Drupal::service('stream_wrapper_manager');
if ($stream_wrapper_manager::getScheme($directory)) {
$directory = $stream_wrapper_manager->normalizeUri($directory);
}
else {
$directory = rtrim($directory, '/\\');
}
if (FileSecurity::writeHtaccess($directory, $private, $force_overwrite)) {
return TRUE;
}
\Drupal::logger('security')->error("Security warning: Couldn't write .htaccess file. Please create a .htaccess file in your %directory directory which contains the following lines: <pre><code>@htaccess</code></pre>", ['%directory' => $directory, '@htaccess' => FileSecurity::htaccessLines($private)]);
return FALSE;
@trigger_error('file_save_htaccess() is deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Component\FileSecurity\FileSecurity::writeHtaccess() instead. See https://www.drupal.org/node/2940126', E_USER_DEPRECATED);
return \Drupal::service('file.htaccess_writer')->write($directory, $private, $force_overwrite);
}
/**
......
......@@ -1100,14 +1100,14 @@ function install_base_system(&$install_state) {
// Install system.module.
drupal_install_system($install_state);
// Call file_ensure_htaccess() to ensure that all of Drupal's standard
// Call HtaccessWriter::ensure() to ensure that all of Drupal's standard
// directories (e.g., the public files directory and config directory) have
// appropriate .htaccess files. These directories will have already been
// created by this point in the installer, since Drupal creates them during
// the install_verify_requirements() task. Note that we cannot call
// file_ensure_access() any earlier than this, since it relies on
// system.module in order to work.
file_ensure_htaccess();
\Drupal::service('file.htaccess_writer')->ensure();
// Prime the drupal_get_filename() static cache with the user module's
// exact location.
......
......@@ -3,6 +3,7 @@
namespace Drupal\Core\Config;
use Drupal\Component\FileCache\FileCacheFactory;
use Drupal\Component\FileSecurity\FileSecurity;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Serialization\Yaml;
......@@ -79,7 +80,7 @@ protected function ensureStorage() {
$success = $this->getFileSystem()->prepareDirectory($dir, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
// Only create .htaccess file in root directory.
if ($dir == $this->directory) {
$success = $success && file_save_htaccess($this->directory, TRUE, TRUE);
$success = $success && FileSecurity::writeHtaccess($this->directory);
}
if (!$success) {
throw new StorageException('Failed to create config directory ' . $dir);
......
......@@ -508,9 +508,6 @@ protected function prepareDestination($source, &$destination, $replace) {
]);
throw new FileException("File '$source' could not be copied because it would overwrite itself.");
}
// Make sure the .htaccess files are present.
// @todo Replace with a service in https://www.drupal.org/project/drupal/issues/2620304.
file_ensure_htaccess();
}
/**
......
<?php
namespace Drupal\Core\File;
use Drupal\Component\FileSecurity\FileSecurity;
use Drupal\Core\Site\Settings;
use Drupal\Core\StreamWrapper\PrivateStream;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Provides functions to manage Apache .htaccess files.
*/
class HtaccessWriter implements HtaccessWriterInterface {
/**
* The stream wrapper manager.
*
* @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
*/
protected $streamWrapperManager;
/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Htaccess constructor.
*
* @param \Psr\Log\LoggerInterface $logger
* The logger.
* @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
* The stream wrapper manager.
*/
public function __construct(LoggerInterface $logger, StreamWrapperManagerInterface $stream_wrapper_manager) {
$this->logger = $logger;
$this->streamWrapperManager = $stream_wrapper_manager;
}
/**
* {@inheritdoc}
*/
public function ensure() {
try {
foreach ($this->defaultProtectedDirs() as $protected_dir) {
$this->write($protected_dir->getPath(), $protected_dir->isPrivate());
}
$staging = Settings::get('config_sync_directory', FALSE);
if ($staging) {
// Note that we log an error here if we can't write the .htaccess file.
// This can occur if the staging directory is read-only. If it is then
// it is the user's responsibility to create the .htaccess file.
$this->write($staging, TRUE);
}
}
catch (\Exception $e) {
$this->logger->error($e->getMessage());
}
}
/**
* Creates a .htaccess file in the given directory.
*
* @param string $directory
* The directory.
* @param bool $deny_public_access
* (Optional) FALSE indicates that $directory should be a web-accessible
* directory. Defaults to TRUE which indicates a private directory.
* @param bool $force_overwrite
* (Optional) Set to TRUE to attempt to overwrite the existing .htaccess
* file if one is already present. Defaults to FALSE.
*
* @internal
*
* @return bool
* TRUE if the .htaccess file was saved or already exists, FALSE otherwise.
*
* @see \Drupal\Component\FileSecurity\FileSecurity::writeHtaccess()
*/
public function write($directory, $deny_public_access = TRUE, $force_overwrite = FALSE) {
if (StreamWrapperManager::getScheme($directory)) {
$directory = $this->streamWrapperManager->normalizeUri($directory);
}
else {
$directory = rtrim($directory, '/\\');
}
if (FileSecurity::writeHtaccess($directory, $deny_public_access, $force_overwrite)) {
return TRUE;
}
$this->logger->error("Security warning: Couldn't write .htaccess file. Please create a .htaccess file in your %directory directory which contains the following lines: <pre><code>@htaccess</code></pre>", ['%directory' => $directory, '@htaccess' => FileSecurity::htaccessLines($deny_public_access)]);
return FALSE;
}
/**
* {@inheritdoc}
*/
public function defaultProtectedDirs() {
$protected_dirs[] = new ProtectedDirectory('Public files directory', 'public://');
if (PrivateStream::basePath()) {
$protected_dirs[] = new ProtectedDirectory('Private files directory', 'private://', TRUE);
}
$protected_dirs[] = new ProtectedDirectory('Temporary files directory', 'temporary://');
return $protected_dirs;
}
}
<?php
namespace Drupal\Core\File;
/**
* Interface for managing Apache .htaccess files.
*/
interface HtaccessWriterInterface {
/**
* Creates a .htaccess file in each Drupal files directory if it is missing.
*/
public function ensure();
/**
* Returns a list of the default protected directories.
*
* @return \Drupal\Core\File\ProtectedDirectory[]
* The default protected directories.
*/
public function defaultProtectedDirs();
}
<?php
namespace Drupal\Core\File;
/**
* A value object representing a protected directory.
*/
class ProtectedDirectory {
/**
* The directory title.
*
* @var string
*/
protected $title;
/**
* The directory path.
*
* @var string
*/
protected $path;
/**
* If the directory is private (or public).
*
* @var bool
*/
protected $private;
/**
* ProtectedDirectory constructor.
*
* @param string $title
* The directory title.
* @param string $path
* The path to the directory.
* @param bool $private
* (optional) Whether the directory is private or public (default).
*/
public function __construct($title, $path, $private = FALSE) {
$this->title = $title;
$this->path = $path;
$this->private = $private;
}
/**
* Gets the title.
*
* @return string
* The Title.
*/
public function getTitle() {
return $this->title;
}
/**
* Gets the directory path.
*
* @return string
* The directory path.
*/
public function getPath() {
return $this->path;
}
/**
* Is the directory private (or public).
*
* @return bool
* TRUE if the directory is private, FALSE if it is public.
*/
public function isPrivate() {
return $this->private;
}
}
......@@ -5,6 +5,7 @@
* Install, update and uninstall functions for the simpletest module.
*/
use Drupal\Component\FileSecurity\FileSecurity;
use Drupal\Component\Utility\Environment;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\Test\TestDatabase;
......@@ -61,7 +62,7 @@ function simpletest_requirements($phase) {
]),
];
}
elseif (!file_save_htaccess(\Drupal::root() . '/' . $site_directory, FALSE)) {
elseif (!FileSecurity::writeHtaccess(\Drupal::root() . '/' . $site_directory, FALSE)) {
$requirements['simpletest_site_directory'] = [
'title' => t('Simpletest site directory'),
'value' => t('Not protected'),
......
......@@ -5,25 +5,26 @@
* Install, update and uninstall functions for the system module.
*/
use Drupal\Component\FileSystem\FileSystem as FileSystemComponent;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\Environment;
use Drupal\Component\FileSystem\FileSystem;
use Drupal\Component\Utility\OpCodeCache;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Cache\Cache;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Path\AliasStorage;
use Drupal\Core\Url;
use Drupal\Core\Database\Database;
use Drupal\Core\DrupalKernel;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\DrupalKernel;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Path\AliasStorage;
use Drupal\Core\Site\Settings;
use Drupal\Core\StreamWrapper\PrivateStream;
use Drupal\Core\StreamWrapper\PublicStream;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Symfony\Component\HttpFoundation\Request;
/**
......@@ -498,32 +499,20 @@ function system_requirements($phase) {
if ($phase == 'runtime') {
// Try to write the .htaccess files first, to prevent false alarms in case
// (for example) the /tmp directory was wiped.
file_ensure_htaccess();
$file_system = \Drupal::service('file_system');
$htaccess_files['public://.htaccess'] = [
'title' => t('Public files directory'),
'directory' => $file_system->realpath('public://'),
];
if (PrivateStream::basePath()) {
$htaccess_files['private://.htaccess'] = [
'title' => t('Private files directory'),
'directory' => $file_system->realpath('private://'),
];
}
$htaccess_files['temporary://.htaccess'] = [
'title' => t('Temporary files directory'),
'directory' => $file_system->realpath('temporary://'),
];
foreach ($htaccess_files as $htaccess_file => $info) {
/** @var \Drupal\Core\File\HtaccessWriterInterface $htaccessWriter */
$htaccessWriter = \Drupal::service("file.htaccess_writer");
$htaccessWriter->ensure();
foreach ($htaccessWriter->defaultProtectedDirs() as $protected_dir) {
$htaccess_file = $protected_dir->getPath() . '/.htaccess';
// Check for the string which was added to the recommended .htaccess file
// in the latest security update.
if (!file_exists($htaccess_file) || !($contents = @file_get_contents($htaccess_file)) || strpos($contents, 'Drupal_Security_Do_Not_Remove_See_SA_2013_003') === FALSE) {
$url = 'https://www.drupal.org/SA-CORE-2013-003';
$requirements[$htaccess_file] = [
'title' => $info['title'],
'title' => new TranslatableMarkup($protected_dir->getTitle()),
'value' => t('Not fully protected'),
'severity' => REQUIREMENT_ERROR,
'description' => t('See <a href=":url">@url</a> for information about the recommended .htaccess file which should be added to the %directory directory to help protect against arbitrary code execution.', [':url' => $url, '@url' => $url, '%directory' => $info['directory']]),
'description' => t('See <a href=":url">@url</a> for information about the recommended .htaccess file which should be added to the %directory directory to help protect against arbitrary code execution.', [':url' => $url, '@url' => $url, '%directory' => $protected_dir->getPath()]),
];
}
}
......@@ -545,7 +534,7 @@ function system_requirements($phase) {
],
],
];
if ($temp_path === FileSystem::getOsTemporaryDirectory()) {
if ($temp_path === FileSystemComponent::getOsTemporaryDirectory()) {
$requirements['temp_directory']['description'][] = [
'#markup' => t('Your temporary directory configuration matches the OS default and can be safely removed.'),
'#suffix' => ' ',
......@@ -655,7 +644,7 @@ function system_requirements($phase) {
else {
// If the temporary directory is not overridden use an appropriate
// temporary path for the system.
$directories[] = FileSystem::getOsTemporaryDirectory();
$directories[] = FileSystemComponent::getOsTemporaryDirectory();
}
}
......@@ -2352,7 +2341,7 @@ function system_update_8801() {
// If settings is already being used, or the config is set to the OS default,
// clear the config value.
$config = Drupal::configFactory()->getEditable('system.file');
if (Settings::get('file_temp_path') || $config->get('path.temporary') === FileSystem::getOsTemporaryDirectory()) {
if (Settings::get('file_temp_path') || $config->get('path.temporary') === FileSystemComponent::getOsTemporaryDirectory()) {
$config->clear('path.temporary')
->save(TRUE);
}
......
......@@ -5,29 +5,30 @@
* Configuration system that lets administrators modify the workings of the site.
*/
use Drupal\Component\FileSecurity\FileSecurity;
use Drupal\Component\Gettext\PoItem;
use Drupal\Core\Extension\Dependency;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Asset\AttachedAssetsInterface;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Queue\QueueGarbageCollectionInterface;
use Drupal\Core\Database\Query\AlterableInterface;
use Drupal\Core\Extension\Dependency;
use Drupal\Core\Extension\Extension;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\KeyValueStore\KeyValueDatabaseExpirableFactory;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\Queue\QueueGarbageCollectionInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Routing\StackedRouteMatchInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\Core\Url;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\user\UserInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use GuzzleHttp\Exception\RequestException;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* New users will be set to the default time zone at registration.
......@@ -948,11 +949,11 @@ function system_check_directory($form_element, FormStateInterface $form_state) {
elseif (is_dir($directory)) {
if ($form_element['#name'] == 'file_public_path') {
// Create public .htaccess file.
file_save_htaccess($directory, FALSE);
FileSecurity::writeHtaccess($directory, FALSE);
}
else {
// Create private .htaccess file.
file_save_htaccess($directory);
FileSecurity::writeHtaccess($directory);
}
}
......@@ -1233,7 +1234,8 @@ function system_get_module_admin_tasks($module, array $info) {
/**
* Implements hook_cron().
*
* Remove older rows from flood, batch cache and expirable keyvalue tables.
* Remove older rows from flood, batch cache and expirable keyvalue tables. Also
* ensure files directories have .htaccess files.
*/
function system_cron() {
// Clean up the flood.
......@@ -1259,6 +1261,10 @@ function system_cron() {
$queue->garbageCollection();
}
}
// Ensure that all of Drupal's standard directories (e.g., the public files
// directory and config directory) have appropriate .htaccess files.
\Drupal::service('file.htaccess_writer')->ensure();
}
/**
......
......@@ -24,14 +24,16 @@ public function testHtaccessSave() {
// Verify that file_save_htaccess() returns FALSE if .htaccess cannot be
// written and writes a correctly formatted message to the error log. Set
// $private to TRUE so all possible .htaccess lines are written.
$this->assertFalse(file_save_htaccess($private, TRUE));
/** @var \Drupal\Core\File\HtaccessWriterInterface $htaccess */
$htaccess = \Drupal::service('file.htaccess_writer');
$this->assertFalse($htaccess->write($private, TRUE));
$this->drupalLogin($this->rootUser);
$this->drupalGet('admin/reports/dblog');
$this->clickLink("Security warning: Couldn't write .htaccess file. Please…");
$lines = FileSecurity::htaccessLines(TRUE);
foreach (array_filter(explode("\n", $lines)) as $line) {
$this->assertEscaped($line);
$this->assertSession()->assertEscaped($line);
}
}
......
......@@ -15,6 +15,20 @@
*/
class DirectoryTest extends FileTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['system'];
protected function setUp() {
parent::setUp();
// These additional tables are necessary due to the call to system_cron().
$this->installSchema('system', ['key_value_expire']);
}
/**
* Test local directory handling functions.
*/
......@@ -97,8 +111,15 @@ public function testFileCheckDirectoryHandling() {
// Remove .htaccess file to then test that it gets re-created.
@$file_system->unlink($default_scheme . '://.htaccess');
$this->assertFalse(is_file($default_scheme . '://.htaccess'), 'Successfully removed the .htaccess file in the files directory.', 'File');
file_ensure_htaccess();
$this->container->get('file.htaccess_writer')->ensure();
$this->assertTrue(is_file($default_scheme . '://.htaccess'), 'Successfully re-created the .htaccess file in the files directory.', 'File');
// Remove .htaccess file again to test that it is re-created by a cron run.
@$file_system->unlink($default_scheme . '://.htaccess');
$this->assertFalse(is_file($default_scheme . '://.htaccess'), 'Successfully removed the .htaccess file in the files directory.', 'File');
system_cron();
$this->assertTrue(is_file($default_scheme . '://.htaccess'), 'Successfully re-created the .htaccess file in the files directory.', 'File');
// Verify contents of .htaccess file.
$file = file_get_contents($default_scheme . '://.htaccess');
$this->assertEqual($file, FileSecurity::htaccessLines(FALSE), 'The .htaccess file contains the proper content.', 'File');
......
<?php
namespace Drupal\KernelTests\Core\File;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Site\Settings;
use Drupal\KernelTests\KernelTestBase;