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, '');
+
+ 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'
+
+
+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