Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2b7c0ac
fix: validate file path in Blueprints::import() to prevent SSRF/LFI
javokhir-sec Jul 3, 2026
7e8bf7f
fix(blueprints): harden import() path validation against SSRF/LFI
javokhir-sec Jul 20, 2026
42bd4e5
fix(blueprints): address Copilot review — TOCTOU, log level, test ass…
javokhir-sec Jul 20, 2026
112279f
fix(test): correct XML canvas key to match getDatabaseType()
javokhir-sec Jul 21, 2026
20823a9
fix(test): use correct box name 'problem' from lean canvas template
javokhir-sec Jul 21, 2026
c594b4f
style: fix pint formatting
javokhir-sec Jul 21, 2026
4b0d1d7
fix(test): apply Copilot review — portability, unique names, cleanup
javokhir-sec Jul 21, 2026
08b82db
fix: use base_path() for userfiles + fix tempnam orphan file
javokhir-sec Jul 21, 2026
8d8f629
fix(test): replace tempnam with rename to avoid orphaned temp files
javokhir-sec Jul 21, 2026
c7b5370
fix(test): proper LFI test using .xml outside allowed dirs
javokhir-sec Jul 21, 2026
9ac10d0
fix: create Blueprints/imports directory for allow-list validation
javokhir-sec Jul 21, 2026
1250cde
fix: remove composer.phar, add to .gitignore, document temp dir .xml …
javokhir-sec Jul 23, 2026
0a13b29
fix: address Copilot review — docstring, extension check, test reliab…
javokhir-sec Jul 23, 2026
8b9af2e
style: apply pint fixes — imports, concat_space, class_definition
javokhir-sec Jul 23, 2026
ed6bdd6
test: add file:// wrapper assertion to SSRF URL test
javokhir-sec Jul 23, 2026
eefdb8c
fix: remove userfiles from import allow-list — authorization bypass
javokhir-sec Jul 23, 2026
c790d4f
fix: add missing <data> and <conclusion> elements to import test XML
javokhir-sec Jul 23, 2026
8b47e05
fix: test reliability, security hardening
javokhir-sec Jul 23, 2026
1603085
docs: clarify sys_get_temp_dir() trust model, restore .gitkeep
javokhir-sec Jul 23, 2026
795a3ac
fix: import and stub UserRepository in import acceptance test
javokhir-sec Jul 23, 2026
0880c26
fix: complete docblock sentence, add LIBXML_NONET against XXE
javokhir-sec Jul 23, 2026
1d1d7e4
test: strengthen ../ traversal test with real .xml file
javokhir-sec Jul 23, 2026
d480fff
fix: use project-relative sibling dir instead of root-level /tmp-evil
javokhir-sec Jul 23, 2026
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
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 +390
* 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.
*
Comment on lines +403 to +408
* @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 +415 to +418
Comment on lines +415 to +418
Comment on lines +415 to +418

// 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 +493 to +500

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

$canvasData = file_get_contents($resolvedPath);
if ($canvasData === false) {
return false;
Comment on lines +517 to 519
}
Comment on lines +502 to 520
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 44 to 47

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
Comment on lines +515 to +519
{
// 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'
);
}
Comment on lines +536 to +548

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('/etc/passwd', 'lean', 55, 1),
'Absolute path to a system file must be rejected'
);
}
Comment on lines +550 to +574

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'
);
Comment on lines +636 to +639
} finally {
unlink($siblingFile);
rmdir($siblingDir);
}
}

public function test_import_rejects_disallowed_file_extensions(): void
{
// Only .xml and .json are permitted. Other extensions must be
// rejected even when the file sits in an allowed directory.
$service = $this->securedService(
Comment on lines +646 to +650
$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