diff --git a/lib/private/Setup.php b/lib/private/Setup.php index 8781127b30afc..1d909781a14fd 100644 --- a/lib/private/Setup.php +++ b/lib/private/Setup.php @@ -215,85 +215,102 @@ public function getSystemInfo(bool $allowAllDatabases = false): array { ]; } - public function createHtaccessTestFile(string $dataDir): string|false { - // php dev server does not support htaccess - if (php_sapi_name() === 'cli-server') { + /** + * Create a temporary htaccess test file for isHtaccessWorking(). + * + * Writes "htaccesstest.txt" into $dataDir and returns its content, or false if skipped. + * + * @return string|false The test content written, or false if the test was skipped + * @throws \OCP\HintException If the test file cannot be created or written + * @internal + */ + private function createHtaccessTestFile(string $dataDir): string|false { + $testFile = $dataDir . '/htaccesstest.txt'; + if (file_exists($testFile)) { // unexpected; possible recursive call return false; } - // testdata - $fileName = '/htaccesstest.txt'; $testContent = 'This is used for testing whether htaccess is properly enabled to disallow access from the outside. This file can be safely removed.'; - - // creating a test file - $testFile = $dataDir . '/' . $fileName; - - if (file_exists($testFile)) {// already running this test, possible recursive call - return false; + $written = @file_put_contents($testFile, $testContent); + if ($written === false) { + throw new \OCP\HintException( + 'Can\'t create htaccess test file to verify .htaccess protection.', + 'Make sure the web server user can write to the data directory (default: /data).' + ); } - $fp = @fopen($testFile, 'w'); - if (!$fp) { - throw new \OCP\HintException('Can\'t create test file to check for working .htaccess file.', - 'Make sure it is possible for the web server to write to ' . $testFile); - } - fwrite($fp, $testContent); - fclose($fp); - return $testContent; } /** - * Check if the .htaccess file is working + * Check whether the .htaccess protection is effective for the given data directory. * - * @param \OCP\IConfig $config - * @return bool - * @throws Exception - * @throws \OCP\HintException If the test file can't get written. + * Creates a temporary file (htaccesstest.txt) under $dataDir and performs an HTTP + * probe. Bypassed under some scenarios (see code) when unnecessary or to avoid false + * negatives. + * + * @return bool True when .htaccess protection appears to work, false otherwise. + * @throws \OCP\HintException If the test file cannot be created. */ - public function isHtaccessWorking(string $dataDir) { - $config = Server::get(IConfig::class); + public function isHtaccessWorking(string $dataDir): bool { - if (\OC::$CLI || !$config->getSystemValueBool('check_for_working_htaccess', true)) { + // Skip quietly to avoid false negatives since web server state unknown in CLI mode + if (\OC::$CLI) { return true; } + // Skip quietly if explicitly configured to do so + if (!(bool)$this->config->getValue('check_for_working_htaccess', true)) { + return true; + } + + // Don't bother probing; we already know PHP's dev server does not support + if (PHP_SAPI === 'cli-server') { + return false; + } + + // Create a temporary htaccess test file $testContent = $this->createHtaccessTestFile($dataDir); - if ($testContent === false) { + if ($testContent === false) { // File already exists for some reason + // Note: createHtaccessTestFile() passes up a HintException for most real-world + // failure scenarios which we currently expect our caller to handle. return false; } - $fileName = '/htaccesstest.txt'; - $testFile = $dataDir . '/' . $fileName; + $testFile = $dataDir . '/htaccesstest.txt'; - // accessing the file via http - $url = Server::get(IURLGenerator::class)->getAbsoluteURL(\OC::$WEBROOT . '/data' . $fileName); - try { - $content = Server::get(IClientService::class)->newClient()->get($url)->getBody(); - } catch (\Exception $e) { - $content = false; - } + // TODO: consider supporting non-default datadirectory + $url = Server::get(IURLGenerator::class)->getAbsoluteURL(\OC::$WEBROOT . '/data/htaccesstest.txt'); - if (str_starts_with($url, 'https:')) { - $url = 'http:' . substr($url, 6); - } else { - $url = 'https:' . substr($url, 5); - } + $client = Server::get(IClientService::class)->newClient(); + $fetch = function (string $target) use ($client, $testContent): string|false { + try { + $resp = $client->get($target); + $body = $resp->getBody(); - try { - $fallbackContent = Server::get(IClientService::class)->newClient()->get($url)->getBody(); - } catch (\Exception $e) { - $fallbackContent = false; - } + if (is_resource($body)) { + $max = strlen($testContent) + 1024; // small margin + return stream_get_contents($body, $max); + } - // cleanup - @unlink($testFile); + return (string)$body; + } catch (\Exception $e) { + return false; + } + }; + + try { + $content = $fetch($url); + // Probe both schemes for full coverage + $fallbackUrl = str_starts_with($url, 'https:') ? 'http:' . substr($url, 6) : 'https:' . substr($url, 5); + $fallbackContent = $fetch($fallbackUrl); - /* - * If the content is not equal to test content our .htaccess - * is working as required - */ - return $content !== $testContent && $fallbackContent !== $testContent; + // .htaccess likely works if content of probes !== the test content + return $content !== $testContent && $fallbackContent !== $testContent; + } finally { + // Always cleanup + @unlink($testFile); + } } /** @@ -540,12 +557,29 @@ private static function findWebRoot(SystemConfig $config): string { } /** - * Append the correct ErrorDocument path for Apache hosts + * Update the default (installation provided) .htaccess by inserting or overwriting + * the non-static section (ErrorDocument and optional front end controller) while + * preserving all static (install artifact) content above the preservation marker. * - * @return bool True when success, False otherwise - * @throws \OCP\AppFramework\QueryException + * Runs regardless of web server in use, but only effective on Apache web servers. + * + * TODO: Make this no longer static (looks easy; few calls) + * + * @return bool True on success; False if not */ public static function updateHtaccess(): bool { + $setupHelper = Server::get(\OC\Setup::class); + $htaccessPath = $setupHelper->pathToHtaccess(); + + // The distributed .htaccess file is required + if (!is_writable($htaccessPath) + || !is_readable($htaccessPath) + ) { + // cannot update .htaccess (bad permissions or it is missing) + return false; + } + + // We're a static method; cannot use $this->config $config = Server::get(SystemConfig::class); try { @@ -554,65 +588,122 @@ public static function updateHtaccess(): bool { return false; } - $setupHelper = Server::get(\OC\Setup::class); + // TODO: Add a check to detect when the .htaccess file isn't the expected one + // (e.g. when it's the datadirectory one due to a misconfiguration) so that we + // don't append to the wrong file (and enable a very problematic configuration). - if (!is_writable($setupHelper->pathToHtaccess())) { + // Read original content + $original = @file_get_contents($htaccessPath); + // extra check for good measure + if ($original === false) { + // bad permissions or installation provided .htaccess is missing return false; } - $htaccessContent = file_get_contents($setupHelper->pathToHtaccess()); - $content = "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####\n"; - $htaccessContent = explode($content, $htaccessContent, 2)[0]; + $preservationBoundary = "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####\n"; + + // Preserve everything above the boundary line; drop the rest (if any) + $parts = explode($preservationBoundary, $original, 2); + $preservedContent = $parts[0]; - //custom 403 error page - $content .= "\nErrorDocument 403 " . $webRoot . '/index.php/error/403'; + // New section must start with the boundary marker + $newContent = $preservationBoundary; - //custom 404 error page - $content .= "\nErrorDocument 404 " . $webRoot . '/index.php/error/404'; + // Handle 403s/404s via primary front controller under all installation scenarios + // ErrorDocument path must be relative to the VirtualHost DocumentRoot + $newContent .= "\nErrorDocument 403 " . $webRoot . '/index.php/error/403'; + $newContent .= "\nErrorDocument 404 " . $webRoot . '/index.php/error/404'; + + // RewriteBase tells mod_rewrite the URL base for the rules in this + // .htaccess file. It is required when Nextcloud is served from a subpath (so the + // rewrite rules generate and match the correct prefixed request paths). It + // also enables "pretty" URLs by routing most requests to the primary front + // controller (index.php). + // + // When served from the document root, RewriteBase is usually not required, + // though some specific server setups may still need it. In Nextcloud, setting + // htaccess.RewriteBase to '/' (instead of leaving it empty or unconfigured) is + // the trigger that causes updateHtaccess() to write the bundled rewrite rules + // and thus enable "pretty" URLs for root installs. - // Add rewrite rules if the RewriteBase is configured $rewriteBase = $config->getValue('htaccess.RewriteBase', ''); + // Notes: + // - Equivalent handling may be provided by the web server (e.g. nginx location + // / Apache vhost blocks) even without this. + // - This is not the entire Nextcloud .htaccess file; these are merely appended + // to the base file distributed with each release. + // TODO: Document these rules/conditions if ($rewriteBase !== '') { - $content .= "\n"; - $content .= "\n Options -MultiViews"; - $content .= "\n RewriteRule ^core/js/oc.js$ index.php [PT,E=PATH_INFO:$1]"; - $content .= "\n RewriteRule ^core/preview.png$ index.php [PT,E=PATH_INFO:$1]"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !\\.(css|js|mjs|svg|gif|ico|jpg|jpeg|png|webp|html|otf|ttf|woff2?|map|webm|mp4|mp3|ogg|wav|flac|wasm|tflite)$"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !/core/ajax/update\\.php"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !/core/img/(favicon\\.ico|manifest\\.json)$"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !/(cron|public|remote|status)\\.php"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !/ocs/v(1|2)\\.php"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !/robots\\.txt"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !/(ocs-provider|updater)/"; - $content .= "\n RewriteCond %{REQUEST_URI} !^/\\.well-known/(acme-challenge|pki-validation)/.*"; - $content .= "\n RewriteCond %{REQUEST_FILENAME} !/richdocumentscode(_arm64)?/proxy.php$"; - $content .= "\n RewriteRule . index.php [PT,E=PATH_INFO:$1]"; - $content .= "\n RewriteBase " . $rewriteBase; - $content .= "\n "; - $content .= "\n SetEnv front_controller_active true"; - $content .= "\n "; - $content .= "\n DirectorySlash off"; - $content .= "\n "; - $content .= "\n "; - $content .= "\n"; - } - - // Never write file back if disk space should be too low - if (function_exists('disk_free_space')) { - $df = disk_free_space(\OC::$SERVERROOT); - $size = strlen($content) + 10240; - if ($df !== false && $df < (float)$size) { - throw new \Exception(\OC::$SERVERROOT . ' does not have enough space for writing the htaccess file! Not writing it back!'); + $newContent .= "\n"; + $newContent .= "\n Options -MultiViews"; + $newContent .= "\n RewriteRule ^core/js/oc.js$ index.php [PT,E=PATH_INFO:$1]"; + $newContent .= "\n RewriteRule ^core/preview.png$ index.php [PT,E=PATH_INFO:$1]"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !\\.(css|js|mjs|svg|gif|ico|jpg|jpeg|png|webp|html|otf|ttf|woff2?|map|webm|mp4|mp3|ogg|wav|flac|wasm|tflite)$"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !/core/ajax/update\\.php"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !/core/img/(favicon\\.ico|manifest\\.json)$"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !/(cron|public|remote|status)\\.php"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !/ocs/v(1|2)\\.php"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !/robots\\.txt"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !/(ocs-provider|updater)/"; + $newContent .= "\n RewriteCond %{REQUEST_URI} !^/\\.well-known/(acme-challenge|pki-validation)/.*"; + $newContent .= "\n RewriteCond %{REQUEST_FILENAME} !/richdocumentscode(_arm64)?/proxy.php$"; + $newContent .= "\n RewriteRule . index.php [PT,E=PATH_INFO:$1]"; + $newContent .= "\n RewriteBase " . $rewriteBase; + $newContent .= "\n "; + $newContent .= "\n SetEnv front_controller_active true"; + $newContent .= "\n "; + $newContent .= "\n DirectorySlash off"; + $newContent .= "\n "; + $newContent .= "\n "; + $newContent .= "\n"; + } + + // Assemble new file contents + $assembled = $preservedContent . $newContent . "\n"; + + // Only write if changed + if ($original !== $assembled) { + // Guard against disk space being too low to safely update + if (function_exists('disk_free_space')) { + $df = disk_free_space(\OC::$SERVERROOT); + $size = strlen($assembled) + 10240; + if ($df !== false && $df < (float)$size) { + throw new \Exception(\OC::$SERVERROOT . ' does not have enough storage space for writing the updated .htaccess file! Giving up!'); + } } + // TODO: Consider atomic write (write to tmp + rename) + $written = @file_put_contents($htaccessPath, $assembled); + return ($written !== false); } - //suppress errors in case we don't have permissions for it - return (bool)@file_put_contents($setupHelper->pathToHtaccess(), $htaccessContent . $content . "\n"); + + return true; } + /** + * Prevents direct HTTP access to user files (high security risk if the + * data directory were web-accessible). + * + * - Prevents directory listing of the data directory. + * - Provides a safe default protection for Apache installs (where .htaccess is honored). + */ public static function protectDataDirectory(): void { - //Require all denied + + $defaultDataDir = \OC::$SERVERROOT . '/data'; + $dataDir = Server::get(IConfig::class)->getSystemValueString('datadirectory', $defaultDataDir); + + // Ensure data directory exists and is writable + if (!is_dir($dataDir) || !is_writable($dataDir)) { + throw new \Exception("Unable to write to data directory ($dataDir) to protect it! Giving up!"); + } + + $dataDirHtaccess = $dataDir . '/.htaccess'; + $dataDirIndex = $dataDir . '/index.html'; + + // Content for the .htaccess file that locks down (most) Apache environments $now = date('Y-m-d H:i:s'); $content = "# Generated by Nextcloud on $now\n"; + $content .= "# Deployed in Nextcloud data directory\n"; + $content .= "# Do not change this file\n\n"; $content .= "# Section for Apache 2.4 to 2.6\n"; $content .= "\n"; $content .= " Require all denied\n"; @@ -637,9 +728,14 @@ public static function protectDataDirectory(): void { $content .= " IndexIgnore *\n"; $content .= ''; - $baseDir = Server::get(IConfig::class)->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'); - file_put_contents($baseDir . '/.htaccess', $content); - file_put_contents($baseDir . '/index.html', ''); + // Create an empty index.html to prevent simply browsing + $writtenIndex = file_put_contents($dataDirIndex, ''); + // Create the .htaccess file + $writtenHtaccess = file_put_contents($dataDirHtaccess, $content); + + if ($writtenHtaccess === false || $writtenIndex === false) { + throw new \Exception("Failed to write $dataDirHtaccess or $dataDirIndex"); + } } private function getVendorData(): array {