Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
8676bc1
fix: use resolveSafeRedirect() in Login POST handler to prevent open …
javokhir-sec Jul 3, 2026
42bd4e5
fix(blueprints): address Copilot review — TOCTOU, log level, test ass…
javokhir-sec Jul 20, 2026
2651dfb
fix(auth): handle same-origin absolute URLs in resolveSafeRedirect()
javokhir-sec Jul 20, 2026
112279f
fix(test): correct XML canvas key to match getDatabaseType()
javokhir-sec Jul 21, 2026
61c80b1
fix(auth): address Copilot review — rawurldecode, dead code removal
javokhir-sec Jul 20, 2026
20823a9
fix(test): use correct box name 'problem' from lean canvas template
javokhir-sec Jul 21, 2026
f89e8ce
fix(auth): add protocol-relative URL tests per maintainer review
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
d549843
Merge branch 'fix/ssrf-lfi-final' into fix/open-redirect-ci-fix
javokhir-sec Jul 21, 2026
3fcf9f2
chore: remove accidentally added files
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
d6cacbf
Merge branch 'fix/ssrf-lfi-final' into fix/open-redirect-ci-fix
javokhir-sec Jul 21, 2026
c7b5370
fix(test): proper LFI test using .xml outside allowed dirs
javokhir-sec Jul 21, 2026
bec0c16
Merge branch 'fix/ssrf-lfi-final' into fix/open-redirect-ci-fix
javokhir-sec Jul 21, 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
12 changes: 2 additions & 10 deletions app/Domain/Auth/Controllers/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,8 @@ public function get(array $params): Response
public function post(array $params): Response
{
if (isset($_POST['username']) === true && isset($_POST['password']) === true) {
if (isset($_POST['redirectUrl'])) {
$redirectUrl = urldecode(filter_var($_POST['redirectUrl'], FILTER_SANITIZE_URL));
} else {
// Browser flow always submits the hidden redirectUrl field. Non-
// browser callers (curl/API/test rigs) may omit it; previously
// this branch yielded '' and Frontcontroller::redirect('') threw
// "Cannot redirect to an empty URL". Match the GET-path default
// from Auth::resolveSafeRedirect().
$redirectUrl = BASE_URL.'/dashboard/home';
}

$redirectUrl = $this->authService->resolveSafeRedirect($_POST['redirectUrl'] ?? null);

Comment on lines 66 to 69
Comment on lines +68 to 69
$username = trim($_POST['username']);
$password = $_POST['password'];
Expand Down
22 changes: 14 additions & 8 deletions app/Domain/Auth/Services/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -486,14 +486,20 @@ public function resolveSafeRedirect(?string $redirect): string
$redirectUrl = BASE_URL.'/dashboard/home';

if ($redirect !== null && trim($redirect) !== '' && trim($redirect) !== '/') {
$url = urldecode($redirect);

// Check for open redirects, don't allow redirects to external sites.
if (
filter_var($url, FILTER_VALIDATE_URL) === false &&
! in_array($url, ['/auth/logout'])
) {
$redirectUrl = BASE_URL.'/'.$url;
$url = rawurldecode($redirect);

Comment on lines 488 to +490
// Strip the application base URL when present so that same-origin
// absolute URLs (e.g. https://my-leantime.com/dashboard/home) are
// treated the same as their relative counterparts. The login form
// may submit a full absolute URL in the redirectUrl field.
if (str_starts_with($url, BASE_URL)) {
Comment on lines +491 to +495
$url = substr($url, strlen(BASE_URL));
}

// Reject external absolute URLs — open redirect guard.
// Relative paths and same-origin URLs stripped above pass through.
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
$redirectUrl = BASE_URL.'/'.ltrim($url, '/');
}
}

Expand Down
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 +387
*
* 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)) {
Comment on lines +405 to +408
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);

Comment on lines 470 to 472
$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);
Comment on lines +473 to +477
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;
}
Expand Down
74 changes: 74 additions & 0 deletions tests/Unit/app/Domain/Auth/Services/AuthServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,80 @@ public function test_resolve_safe_redirect_blocks_external_url(): void
);
}

public function test_resolve_safe_redirect_allows_same_origin_absolute_url(): void
{
$service = $this->makeService();

// Same-origin absolute URL — must be treated the same as a relative
// path by stripping the BASE_URL prefix. This is the exact scenario
// the maintainer flagged: the login form often submits a full
// absolute URL in the redirectUrl hidden field.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect(BASE_URL.'/dashboard/home')
);
}

public function test_resolve_safe_redirect_allows_same_origin_absolute_url_with_deep_path(): void
{
$service = $this->makeService();

$this->assertSame(
BASE_URL.'/tickets/showAll',
$service->resolveSafeRedirect(BASE_URL.'/tickets/showAll')
);
}

public function test_resolve_safe_redirect_allows_url_encoded_same_origin_absolute_url(): void
{
$service = $this->makeService();

// URL-encoded same-origin absolute URL — rawurldecode is called first,
// then BASE_URL is stripped.
$this->assertSame(
BASE_URL.'/tickets/showAll',
$service->resolveSafeRedirect(urlencode(BASE_URL.'/tickets/showAll'))
);
}

public function test_resolve_safe_redirect_rejects_external_url_disguised_with_base_url_prefix(): void
{
$service = $this->makeService();

// An external URL whose path happens to start with the same characters
// as BASE_URL — str_starts_with won't match because the scheme+host
// differ. This gets rejected as external.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('https://evil.example.com/'.BASE_URL.'/dashboard/home')
);
}

public function test_resolve_safe_redirect_rejects_protocol_relative_url(): void
{
$service = $this->makeService();

// Protocol-relative URL (//attacker.com) — FILTER_VALIDATE_URL
// treats these as valid URLs, so they are correctly rejected
// and the default dashboard redirect is returned.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('//attacker.com')
);
}

public function test_resolve_safe_redirect_rejects_backslash_protocol_trick(): void
{
$service = $this->makeService();

// Backslash variant (\/\/attacker.com) — some parsers treat
// this as a protocol-relative URL. Verify it is rejected.
$this->assertSame(
BASE_URL.'/dashboard/home',
$service->resolveSafeRedirect('\/\/attacker.com')
);
}

public function test_check_password_strength_rejects_weak_and_accepts_strong(): void
{
$service = $this->makeService();
Expand Down
Loading
Loading