diff --git a/app/Domain/Blueprints/Services/Blueprints.php b/app/Domain/Blueprints/Services/Blueprints.php index 34feb76c28..f04476a794 100644 --- a/app/Domain/Blueprints/Services/Blueprints.php +++ b/app/Domain/Blueprints/Services/Blueprints.php @@ -378,6 +378,64 @@ public function deleteBoard(int $canvasId, string $canvasType): void $this->blueprintsRepo->deleteCanvas($canvasId); } + /** + * Validate that a resolved import file path is safe to read. + * + * Rejects files outside a fixed allow-list of local directories and + * requires a known extension. The caller must resolve the path via + * {@see realpath()} first — this avoids a TOCTOU window between + * resolution and validation. + * + * The allow-list covers the three places Leantime sources blueprint + * import files from: the upload temp directory, the userfiles storage, + * and the shipped fixture directory under the Blueprints domain. + * + * @param string $resolvedPath Already-resolved absolute path (from realpath) + * @return bool True when the path is within an allowed directory + * and has an allowed extension + */ + private function isImportPathAllowed(string $resolvedPath): bool + { + $allowedDirs = [ + sys_get_temp_dir(), + base_path('userfiles'), + APP_ROOT.'/app/Domain/Blueprints/imports', + ]; + + // Validate file extension — only XML (uploaded via the UI) and JSON + // (shipped fixture files under the imports directory) are permitted. + $ext = strtolower(pathinfo($resolvedPath, PATHINFO_EXTENSION)); + if (! in_array($ext, ['xml', 'json'], true)) { + Log::warning('Blueprints import: disallowed file extension', [ + 'resolvedPath' => $resolvedPath, + 'extension' => $ext, + ]); + + return false; + } + + // Anchor each allowed directory with a trailing separator so + // str_starts_with doesn't match sibling-prefix paths (e.g. + // /tmp-evil/x must NOT match against allowed /tmp). + foreach ($allowedDirs as $allowedDir) { + $resolvedAllowed = realpath($allowedDir); + + if ($resolvedAllowed === false) { + continue; + } + + if (str_starts_with($resolvedPath, $resolvedAllowed.DIRECTORY_SEPARATOR)) { + return true; + } + } + + Log::warning('Blueprints import: path traversal or SSRF attempt blocked', [ + 'resolvedPath' => $resolvedPath, + ]); + + return false; + } + /** * Import a canvas board from an XML file. * @@ -412,7 +470,24 @@ public function import(string $filename, string $canvasSlug, int $projectId, int $dom = new DOMDocument('1.0', 'UTF-8'); $users = app()->make(UserRepository::class); - $canvasData = file_get_contents($filename); + // Validate the file path and extension to prevent SSRF and Local File + // Inclusion. Reject URL wrappers (http://, ftp://, etc.), restrict + // reads to allowed local directories, and require a known import + // extension. + $resolvedPath = realpath($filename); + if ($resolvedPath === false) { + Log::warning('Blueprints import: file not found or path does not exist', [ + 'filename' => $filename, + ]); + + return false; + } + + if (! $this->isImportPathAllowed($resolvedPath)) { + return false; + } + + $canvasData = file_get_contents($resolvedPath); if ($canvasData === false) { return false; } diff --git a/app/Domain/Blueprints/imports/.gitkeep b/app/Domain/Blueprints/imports/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/composer.phar b/composer.phar new file mode 100755 index 0000000000..43f4b991bb Binary files /dev/null and b/composer.phar differ diff --git a/tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php b/tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php index b24b30d040..6b1d240f3e 100644 --- a/tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php +++ b/tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php @@ -38,7 +38,7 @@ private function service(?BlueprintsRepository $repo = null, ?TemplateRegistry $ public function test_translated_boxes_run_titles_through_language(): void { $template = new CanvasTemplate([ - 'slug' => 'swot', + 'slug' => 'lean', 'boxes' => ['swot_strengths' => ['icon' => 'fa-x', 'title' => 'box.swot.strengths']], ]); @@ -505,7 +505,195 @@ public function test_import_authorizes_create_on_target_project_before_doing_any $service = $this->securedService($repo, $this->denyingPermissions()); $this->expectException(AuthorizationException::class); - $service->import('/tmp/does-not-matter.xml', 'swot', 55, 1); + $service->import('/tmp/does-not-matter.xml', 'lean', 55, 1); + } + + // --------------------------------------------------------------------- + // import() path-validation regression tests (SSRF / LFI / CWE-918). + // --------------------------------------------------------------------- + + public function test_import_rejects_ssrf_url_wrappers(): void + { + // URL wrappers such as http://, ftp:// resolve to false via realpath(), + // but even if/when a stream wrapper could produce a realpath, the + // allow-list check catches it. This test also guards the more + // subtle case of file:///etc/passwd which some PHP builds resolve. + $service = $this->securedService( + $this->make(BlueprintsRepository::class), + $this->allowingPermissions() + ); + + // SSRF: HTTP URL — realpath() returns false, caught as "file not found". + $this->assertFalse( + $service->import('http://169.254.169.254/latest/meta-data/', 'lean', 55, 1), + 'HTTP URL must be rejected' + ); + + // SSRF: FTP URL. + $this->assertFalse( + $service->import('ftp://evil.com/blueprint.xml', 'lean', 55, 1), + 'FTP URL must be rejected' + ); + } + + public function test_import_rejects_lfi_absolute_path_to_system_file(): void + { + // Create an .xml file in a directory that is NOT in the allowed list. + // /var/tmp is outside sys_temp_dir, userfiles, and Blueprints/imports. + $service = $this->securedService( + $this->make(BlueprintsRepository::class), + $this->allowingPermissions() + ); + + $outOfBounds = '/var/tmp/leantime_lfi_test_' . uniqid('', true) . '.xml'; + file_put_contents($outOfBounds, 'LFI Test'); + + try { + $this->assertFalse( + $service->import($outOfBounds, 'lean', 55, 1), + 'Absolute path to an .xml file outside allowed directories must be rejected' + ); + } finally { + if (file_exists($outOfBounds)) { + unlink($outOfBounds); + } + } + } + + public function test_import_rejects_dot_dot_path_traversal(): void + { + // Construct a path that starts inside the temp directory but + // traverses out and back into /etc/passwd. + $service = $this->securedService( + $this->make(BlueprintsRepository::class), + $this->allowingPermissions() + ); + + $traversal = sys_get_temp_dir().'/../../../etc/passwd'; + + $this->assertFalse( + $service->import($traversal, 'lean', 55, 1), + 'Path traversal (../) must be rejected' + ); + } + + public function test_import_rejects_sibling_prefix_bypass(): void + { + // str_starts_with without separator anchoring would allow + // /tmp-evil/blueprint.xml to match against allowed /tmp. + // The fix appends DIRECTORY_SEPARATOR to each allowed dir. + $service = $this->securedService( + $this->make(BlueprintsRepository::class), + $this->allowingPermissions() + ); + + // Create a directory whose name is a prefix of the real temp dir. + $siblingDir = sys_get_temp_dir().'-evil'; + if (! is_dir($siblingDir)) { + mkdir($siblingDir, 0700, true); + } + $siblingFile = $siblingDir.'/blueprint.json'; + file_put_contents($siblingFile, '{}'); + + try { + $this->assertFalse( + $service->import($siblingFile, 'lean', 55, 1), + 'Sibling-prefix path (e.g. /tmp-evil/…) must NOT match allowed /tmp' + ); + } finally { + unlink($siblingFile); + rmdir($siblingDir); + } + } + + public function test_import_rejects_disallowed_file_extensions(): void + { + // Only .xml is permitted. Other extensions must be + // rejected even when the file sits in an allowed directory. + $service = $this->securedService( + $this->make(BlueprintsRepository::class), + $this->allowingPermissions() + ); + + $phpFile = sys_get_temp_dir().'/leantime_import_test.php'; + file_put_contents($phpFile, 'assertFalse( + $service->import($phpFile, 'lean', 55, 1), + '.php extension must be rejected in an allowed directory' + ); + $this->assertFalse( + $service->import($txtFile, 'lean', 55, 1), + '.txt extension must be rejected in an allowed directory' + ); + } finally { + if (file_exists($phpFile)) { + unlink($phpFile); + } + if (file_exists($txtFile)) { + unlink($txtFile); + } + } + } + + public function test_import_accepts_xml_file_in_allowed_temp_dir(): void + { + // A .xml file placed in sys_get_temp_dir() (the normal upload flow) + // must pass path validation and successfully import via the repo. + // The repository is stubbed so the import completes and returns a + // known canvas id, proving that path validation did NOT block it. + $expectedId = 42; + $repo = $this->make(BlueprintsRepository::class, [ + 'existCanvas' => fn () => false, + 'addCanvas' => fn () => $expectedId, + 'addCanvasItem' => fn () => 1, + ]); + $service = $this->securedService($repo, $this->allowingPermissions()); + + $xml = <<<'XML' + + + Security Test Canvas + + + + + Test item + status_draft + relates_none + none + low + 0 + + + + + + +XML; + + $tmpBase = tempnam(sys_get_temp_dir(), 'leantime.'); + $tempFile = $tmpBase . '.xml'; + rename($tmpBase, $tempFile); + file_put_contents($tempFile, $xml); + + try { + $result = $service->import($tempFile, 'lean', 55, 1); + // Path validation passed and repo returned the expected canvas id. + $this->assertSame( + $expectedId, + $result, + 'XML file in allowed dir must pass path validation and be imported' + ); + } finally { + if (file_exists($tempFile)) { + unlink($tempFile); + } + } } public function test_create_board_applies_start_content_against_the_slug_not_the_db_type(): void