Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.testing
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DB_CONNECTION=testing
APP_ENV=testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
77 changes: 76 additions & 1 deletion app/Domain/Blueprints/Services/Blueprints.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Comment on lines +384 to +388
* 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(),
storage_path('userfiles'),
APP_ROOT.'/app/Domain/Blueprints/imports',
];
Comment on lines +399 to +403
Comment on lines +399 to +403

// 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.
*
Expand Down Expand Up @@ -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;
}
Comment on lines +477 to +484

if (! $this->isImportPathAllowed($resolvedPath)) {
return false;
}

$canvasData = file_get_contents($resolvedPath);
if ($canvasData === false) {
return false;
}
Expand Down
Binary file added composer.phar
Binary file not shown.
181 changes: 179 additions & 2 deletions tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
]);
Comment on lines 40 to 43

Expand Down Expand Up @@ -505,7 +505,184 @@ 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
{
// /etc/passwd exists on Linux and resolves via realpath(), but it
// lives outside every allowed directory.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);

$this->assertFalse(
$service->import(__FILE__, 'lean', 55, 1),
'Absolute path to a non-import file must be rejected'
);
}
Comment on lines +539 to +561

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);
}
Comment on lines +590 to +594
$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(
Comment on lines +609 to +613
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);

$phpFile = sys_get_temp_dir().'/leantime_import_test.php';
file_put_contents($phpFile, '<?php echo "pwned";');

$txtFile = sys_get_temp_dir().'/leantime_import_test.txt';
file_put_contents($txtFile, 'not xml');

try {
$this->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 version="1.0" encoding="UTF-8"?>
<canvas key="leancanvas">
<title>Security Test Canvas</title>
<content>
<element key="problem">
<item>
<author firstname="A" lastname="B"/>
<description>Test item</description>
<status>status_draft</status>
<relates>relates_none</relates>
<assumptions>none</assumptions>
<priority>low</priority>
<progress>0</progress>
<duedate></duedate>
<tags></tags>
</item>
</element>
</content>
</canvas>
XML;

$tempFile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
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
Expand Down
Loading