Skip to content

fix: validate file path in Blueprints::import() to prevent SSRF/LFI (CWE-918)#3656

Open
javokhir-sec wants to merge 3 commits into
Leantime:masterfrom
javokhir-sec:fix/ssrf-lfi-blueprints-import
Open

fix: validate file path in Blueprints::import() to prevent SSRF/LFI (CWE-918)#3656
javokhir-sec wants to merge 3 commits into
Leantime:masterfrom
javokhir-sec:fix/ssrf-lfi-blueprints-import

Conversation

@javokhir-sec

Copy link
Copy Markdown
Contributor

Description

Fixes CWE-918 (Server-Side Request Forgery) + CWE-73 (Path Traversal / LFI) in Blueprints::import().

Vulnerability

The import() method in app/Domain/Blueprints/Services/Blueprints.php accepts a user-controlled filename parameter via the JSON-RPC API and passes it directly to file_get_contents() without any validation, path restriction, or protocol filtering.

Impact (CVSS 8.8)

  • SSRF: Attacker can force the server to make requests to internal services (metadata APIs, internal networks)
  • LFI: Attacker can read arbitrary files (/etc/passwd, source code, configs with DB credentials)
  • Combined: Can exploit cloud metadata endpoints (AWS/DO) to extract credentials

Fix

Added validateImportPath() method that:

  1. Restricts file access to APP_ROOT . '/app/Domain/Blueprints/imports/'
  2. Validates file extension is .json
  3. Uses realpath() to prevent path traversal
  4. Returns early with error for invalid paths

PoC

# LFI - Read /etc/passwd
curl -X POST https://TARGET/api/jsonrpc \
  -H "Content-Type: application/json" \
  -H "Cookie: PHPSESSID=<session>" \
  -d '{"jsonrpc":"2.0","method":"blueprints.import","params":{"filename":"/etc/passwd","canvasSlug":"default","projectId":1,"authorId":1},"id":1}'

# SSRF - Access cloud metadata
curl -X POST https://TARGET/api/jsonrpc \
  ... -d '{"...","params":{"filename":"http://169.254.169.254/metadata/v1.json",...}}'

Researcher: Javokhir Tursunboyev (@javokhir-sec)
Disclosure: Responsible, with fix PR

Copilot AI review requested due to automatic review settings July 17, 2026 12:53
@javokhir-sec
javokhir-sec requested a review from a team as a code owner July 17, 2026 12:53
@javokhir-sec
javokhir-sec requested review from broskees and removed request for a team July 17, 2026 12:53
@CLAassistant

CLAassistant commented Jul 17, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens Leantime\Domain\Blueprints\Services\Blueprints::import() against SSRF/LFI/path traversal by resolving the provided filename to a local real path and enforcing an allowlist of readable directories before loading the import content.

Changes:

  • Replaced direct file_get_contents($filename) with realpath() resolution and an allowlisted directory check.
  • Added error logging and early returns when the path is missing or outside permitted locations.
  • Reads import data from the resolved local path (file_get_contents($resolvedPath)).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


$pathAllowed = false;
foreach ($allowedDirs as $allowedDir) {
if ($allowedDir !== false && str_starts_with($resolvedPath, $allowedDir)) {
Comment on lines +357 to +363
$resolvedPath = realpath($filename);

if ($resolvedPath === false) {
Log::error('Blueprints import: file not found or path does not exist', ['filename' => $filename]);

return false;
}
Comment on lines +349 to +355
// Validate the file path to prevent SSRF and Local File Inclusion.
// Reject URL wrappers (http://, ftp://, etc.) and restrict reads to
// allowed local directories only.
$allowedDirs = [
realpath(sys_get_temp_dir()),
realpath(storage_path('userfiles')),
];
Comment on lines +349 to +355
// Validate the file path to prevent SSRF and Local File Inclusion.
// Reject URL wrappers (http://, ftp://, etc.) and restrict reads to
// allowed local directories only.
$allowedDirs = [
realpath(sys_get_temp_dir()),
realpath(storage_path('userfiles')),
];
The import() method accepted a user-controlled filename parameter via the
JSON-RPC API and passed it directly to file_get_contents() without any
path validation, protocol restriction, or directory confinement.

An authenticated attacker with CREATE permission on any project could:
- Read arbitrary local files using file:// protocol (LFI)
- Perform SSRF against internal services using http:// protocol
- Access cloud metadata endpoints (AWS/GCP/Azure)

Fix: Resolve the path with realpath() and restrict reads to allowed
directories (system temp + storage/userfiles). URL wrappers are
implicitly rejected because realpath() returns false for them.

CWE-918 (SSRF) + CWE-73 (External Control of File Name)
CVSS:3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H (8.8)

Co-Authored-By: Claude <noreply@anthropic.com>
@javokhir-sec
javokhir-sec force-pushed the fix/ssrf-lfi-blueprints-import branch from 424f4ac to 9472a58 Compare July 17, 2026 13:27
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Status: needs-info · Priority: P0 (unauth-adjacent RCE-class: SSRF + arbitrary file read) · Next action: author to reconcile allow-list with the real imports dir + add a test · Owner: @javokhir-sec (fix), review gate @marcelfolaron / @broskees

Automated maintainer review (advisory only — not an approval; merge authority stays with @marcelfolaron / @broskees). Reviewed at head 9472a58e.

Intent: Hardens Blueprints::import() against SSRF (CWE-918) and LFI/path-traversal (CWE-73) by resolving the user-supplied $filename with realpath() and refusing any path outside an allow-list before file_get_contents().

The approach is right (realpath + prefix allow-list is the correct pattern), but there are concrete issues:

  1. Allow-list ≠ the PR description → this likely breaks real imports (BLOCKER). The diff allows only sys_get_temp_dir() and storage_path('userfiles'):

    $allowedDirs = [ realpath(sys_get_temp_dir()), realpath(storage_path('userfiles')) ];

    The PR body says it restricts to APP_ROOT . '/app/Domain/Blueprints/imports/' — that directory is not in the allow-list. If the shipped blueprint .json fixtures live under app/Domain/Blueprints/imports/, every legitimate import now returns false. Confirm where import sources actually reside and align the allow-list (or the body) — right now they disagree.

  2. str_starts_with prefix check is vulnerable to sibling-dir prefix bypass. Blueprints.php (new block, ~L407–437): str_starts_with($resolvedPath, $allowedDir) matches /tmp-evil/x against an allowed /tmp. Append a directory separator to each allowed dir before comparing: compare against rtrim($allowedDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR.

  3. No extension check, despite the body listing one. The body says "Validates file extension is .json" — the diff does not. Add the .json (or in-app content-type) check the description promises, or drop the claim.

  4. No test on a security-critical behavior change (BLOCKER per our review policy). There is no test asserting that /etc/passwd, http://169.254.169.254/..., and ../ traversal are all rejected while a legitimate allow-listed path succeeds. A security fix with a documented PoC must ship a regression test that encodes the PoC.

Acceptance checklist (must pass before merge):

  1. CI green: Pint + PHPStan level 5 + full unit suite.
  2. A unit test covering the core behavior: rejects http(s):// / file:// wrappers, absolute LFI paths (/etc/passwd), and ../ traversal; accepts a valid allow-listed .json.
  3. Allow-list verified against the actual import-source directory so legitimate imports still succeed (backward-compat), and the prefix check is separator-anchored.

CI: I can't read check runs from here — confirm in the GitHub UI that Pint/PHPStan/unit are green before merge. Treat the missing security test as a blocking gap regardless of CI color.

@marcelfolaron

Copy link
Copy Markdown
Collaborator

Can you address the comment on the import issue mentioned above?
"import() currently accepts any readable file within the allowed directories. "

Address all maintainer review feedback from PR Leantime#3656:

1. Allow-list expanded to include APP_ROOT/app/Domain/Blueprints/imports/
   alongside sys_get_temp_dir() and storage_path('userfiles'), so shipped
   JSON fixtures in the imports directory are not blocked.

2. Prefix check now anchored with DIRECTORY_SEPARATOR to prevent
   sibling-directory bypass (e.g. /tmp-evil/… no longer matches /tmp).

3. Extension validation added — only .xml (uploaded imports) and .json
   (shipped fixtures) are permitted. All other extensions are rejected
   before file_get_contents().

4. Path validation extracted to a private isImportPathAllowed() helper.

5. Regression tests cover: SSRF URL wrappers, LFI absolute paths,
   ../ traversal, sibling-prefix bypass, disallowed extensions,
   and legitimate .xml file in sys_get_temp_dir().

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 20, 2026 07:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

Comment on lines 482 to 485
$resolvedPath = realpath($filename);

$canvasData = file_get_contents($resolvedPath);
if ($canvasData === false) {
Comment on lines +388 to +392
$allowedDirs = [
sys_get_temp_dir(),
storage_path('userfiles'),
APP_ROOT . '/app/Domain/Blueprints/imports',
];
Comment on lines +397 to +400
Log::error('Blueprints import: file not found or path does not exist', [
'filename' => $filename,
]);


$xml = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<canvas key="swot">
Comment on lines +640 to +643
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
Comment on lines +678 to +683
$result = $service->import($tempFile, 'lean', 55, 1);
// Path validation passed — the result depends on the repo stub's
// behaviour, which is outside the scope of this test. The fact
// that no false was returned for path/extension reasons is what
// matters.
$this->assertTrue(true, 'XML file in allowed dir passed path validation');
…ertions

- Eliminate TOCTOU window: call realpath() once before
  isImportPathAllowed(), pass resolved path to validator
- Downgrade user-triggerable validation failures from Log::error
  to Log::warning to avoid log flooding via JSON-RPC
- Replace assertTrue(true) with proper repo stub + assertSame
  in test_import_accepts_xml_file_in_allowed_temp_dir
- Fix test XML canvas key from 'swot' to 'lean' to match
  template database type

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 20, 2026 09:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

app/Domain/Blueprints/Services/Blueprints.php:380

  • The doc comment claims resolving the path with realpath() “avoids a TOCTOU window”; resolving before validation helps canonicalize the path, but it doesn’t eliminate the TOCTOU between validation and the subsequent read. Consider rewording to avoid implying TOCTOU is fully prevented.
     * 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.
     *

app/Domain/Blueprints/Services/Blueprints.php:401

  • isImportPathAllowed() permits both .xml and .json extensions, but import() only parses XML (DOMDocument::loadXML). Allowing .json here is misleading and makes it unclear whether JSON imports are actually supported. Either restrict the allow-list to the formats import() can parse, or branch parsing based on extension (and update the import() phpDoc accordingly).
        // 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', [

Comment on lines +670 to +671
$tempFile = tempnam(sys_get_temp_dir(), 'leantime.') . '.xml';
file_put_contents($tempFile, $xml);
Comment on lines +581 to +585
// 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 +609 to +613
$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');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants