Skip to content

fix: enable CSRF protection globally with @csrf on all Blade forms (CWE-352)#3670

Open
javokhir-sec wants to merge 21 commits into
Leantime:masterfrom
javokhir-sec:fix/csrf-global-enable
Open

fix: enable CSRF protection globally with @csrf on all Blade forms (CWE-352)#3670
javokhir-sec wants to merge 21 commits into
Leantime:masterfrom
javokhir-sec:fix/csrf-global-enable

Conversation

@javokhir-sec

Copy link
Copy Markdown
Contributor

Summary

Fixes CWE-352 — CSRF Protection Globally Disabled in Leantime.

This replaces the previously closed #3659. The original was rejected because enabling CSRF globally would break ~82 untokenized forms. This PR completes ALL the prerequisite work.

Changes

1. @csrf added to 92 Blade templates with POST forms

Every Blade-based POST form now includes the @csrf directive. Affected domains: Auth, Calendar, Clients, Comments, Connector, Files, Goalcanvas, Ideas, Ldap, Projects, Tickets, Timesheets, Users, Widgets, Wiki, Setting, and more.

2. VerifyCsrfToken middleware enabled globally

Uncommented \Leantime\Core\Middleware\VerifyCsrfToken::class in app/Core/Http/HttpKernel.php.

Routes excluded from CSRF:

  • api/* — JSON-RPC uses session cookie auth
  • cron/* — server-to-server calls
  • webhook/* — external webhooks
  • install, install/* — no session during install

CSRF token delivery (already in place upstream):

  • <meta name="csrf-token"> in header.blade.php (jQuery form plugin)
  • hx-headers with X-CSRF-TOKEN on <body> tag (HTMX requests)
  • @csrf Blade directive now present in all POST forms

CVSS

8.8 (High) — CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Co-Authored-By: Claude noreply@anthropic.com
🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 20, 2026 07:30
@javokhir-sec
javokhir-sec requested a review from a team as a code owner July 20, 2026 07:30
@javokhir-sec
javokhir-sec requested review from marcelfolaron and removed request for a team July 20, 2026 07:30

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 aims to close a long-standing CSRF gap (CWE-352) by enabling Laravel’s CSRF verification middleware globally and ensuring Blade POST forms include CSRF tokens via @csrf.

Changes:

  • Enabled \Leantime\Core\Middleware\VerifyCsrfToken::class in the global middleware stack (app/Core/Http/HttpKernel.php).
  • Added @csrf to many Blade templates containing POST forms across multiple domains.

Reviewed changes

Copilot reviewed 93 out of 93 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
app/Domain/Wiki/Templates/wikiDialog.blade.php Adds @csrf to wiki modal POST form.
app/Domain/Wiki/Templates/show.blade.php Adds @csrf to wiki comment form (currently broken by @csrfid insertion).
app/Domain/Wiki/Templates/delWiki.blade.php Adds @csrf to wiki delete form (currently broken by @csrfescape insertion).
app/Domain/Wiki/Templates/delArticle.blade.php Adds @csrf to article delete form.
app/Domain/Wiki/Templates/articleDialog.blade.php Adds @csrf to article dialog POST form.
app/Domain/Widgets/Templates/partials/myToDos.blade.php Adds @csrf to HTMX todo forms.
app/Domain/Users/Templates/newUser.blade.php Adds @csrf to new-user POST form.
app/Domain/Users/Templates/importLdapDialog.blade.php Adds @csrf to LDAP import POST forms.
app/Domain/Users/Templates/editUser.blade.php Adds @csrf to user edit POST form.
app/Domain/Users/Templates/editOwn.blade.php Adds @csrf to multiple profile/settings POST forms.
app/Domain/Users/Templates/delUser.blade.php Adds @csrf to delete-user confirmation form.
app/Domain/TwoFA/Templates/verify.blade.php Adds @csrf to 2FA verify POST form.
app/Domain/TwoFA/Templates/edit.blade.php Adds @csrf to 2FA enable/disable POST forms.
app/Domain/Timesheets/Templates/showMyList.blade.php Adds @csrf to timesheet list filter POST form.
app/Domain/Timesheets/Templates/showMy.blade.php Adds @csrf to timesheet list POST form.
app/Domain/Timesheets/Templates/showAll.blade.php Adds @csrf to timesheet admin list POST form.
app/Domain/Timesheets/Templates/editTime.blade.php Adds @csrf to edit-time POST form.
app/Domain/Timesheets/Templates/delTime.blade.php Adds @csrf to delete-time POST form.
app/Domain/Timesheets/Templates/addTime.blade.php Adds @csrf to search and add-time POST forms.
app/Domain/Tickets/Templates/submodules/timesheet.blade.php Adds @csrf to ticket timesheet POST form (currently broken by @csrfid insertion).
app/Domain/Tickets/Templates/submodules/ticketDetails.blade.php Adds @csrf to ticket comment POST form (currently broken by @csrfid insertion).
app/Domain/Tickets/Templates/submodules/subTasks.blade.php Adds @csrf to quick-add subtask POST form (currently broken by @csrfid insertion).
app/Domain/Tickets/Templates/showTicketModal.blade.php Adds @csrf to ticket modal POST form.
app/Domain/Tickets/Templates/showTicket.blade.php Adds @csrf to ticket details/files/comment POST forms (comment form currently broken by @csrfid insertion).
app/Domain/Tickets/Templates/showList.blade.php Adds @csrf to quick-add ticket form.
app/Domain/Tickets/Templates/showAll.blade.php Adds @csrf to quick-add ticket form.
app/Domain/Tickets/Templates/partials/subtasks.blade.php Adds @csrf to HTMX subtasks form (currently broken by @csrfid insertion in hx-post).
app/Domain/Tickets/Templates/partials/quickadd-form.blade.php Adds @csrf to quick-add ticket form partial.
app/Domain/Tickets/Templates/newTicketModal.blade.php Adds @csrf to new-ticket modal POST form.
app/Domain/Tickets/Templates/newTicket.blade.php Adds @csrf to new-ticket POST form.
app/Domain/Tickets/Templates/moveTicket.blade.php Adds @csrf to move-ticket POST form (currently broken by @csrfid insertion).
app/Domain/Tickets/Templates/milestoneDialog.blade.php Adds @csrf to milestone edit POST form (currently broken by @csrfid insertion).
app/Domain/Tickets/Templates/delTicket.blade.php Adds @csrf to delete-ticket POST form (currently broken by @csrfid insertion).
app/Domain/Tickets/Templates/delMilestone.blade.php Adds @csrf to delete-milestone POST form (currently broken by @csrfid insertion).
app/Domain/Sprints/Templates/sprintdialog.blade.php Adds @csrf to sprint dialog POST form.
app/Domain/Sprints/Templates/delSprint.blade.php Adds @csrf to sprint delete POST form.
app/Domain/Setting/Templates/editCompanySettings.blade.php Adds @csrf to company settings POST forms.
app/Domain/Setting/Templates/editBoxDialog.blade.php Adds @csrf to edit-box-label POST form (currently broken by @csrf inserted into http_build_query(...)).
app/Domain/Projects/Templates/submodules/projectDetails.blade.php Adds @csrf to project details POST form.
app/Domain/Projects/Templates/showProject.blade.php Adds @csrf to multiple project settings/integration POST forms.
app/Domain/Projects/Templates/showAll.blade.php Adds @csrf to projects filter POST form.
app/Domain/Projects/Templates/newProject.blade.php Adds @csrf to new-project POST form.
app/Domain/Projects/Templates/duplicateProject.blade.php Adds @csrf to duplicate-project POST form.
app/Domain/Projects/Templates/delProject.blade.php Adds @csrf to delete-project POST form.
app/Domain/Logicmodelcanvas/Templates/canvasDialog.blade.php Adds @csrf to canvas item edit POST form.
app/Domain/Install/Templates/update.blade.php Adds @csrf to installer update POST form.
app/Domain/Install/Templates/new.blade.php Adds @csrf to installer POST form.
app/Domain/Ideas/Templates/showBoards.blade.php Adds @csrf to idea board modal POST forms.
app/Domain/Ideas/Templates/ideaDialog.blade.php Adds @csrf to idea dialog POST form.
app/Domain/Ideas/Templates/delCanvasItem.blade.php Adds @csrf to delete idea item POST form.
app/Domain/Ideas/Templates/delCanvas.blade.php Adds @csrf to delete idea board POST form (currently broken by @csrfescape insertion).
app/Domain/Ideas/Templates/advancedBoards.blade.php Adds @csrf to advanced boards modal POST forms.
app/Domain/Help/Templates/projectIntroStep.blade.php Adds @csrf to onboarding POST form.
app/Domain/Help/Templates/projectDefinitionStep.blade.php Adds @csrf to onboarding POST form.
app/Domain/Help/Templates/inviteTeamStep.blade.php Adds @csrf to onboarding POST form.
app/Domain/Help/Templates/firstTaskStep.blade.php Adds @csrf to onboarding POST form.
app/Domain/Goalcanvas/Templates/delCanvasItem.blade.php Adds @csrf to goalcanvas delete item POST form.
app/Domain/Goalcanvas/Templates/canvasDialog.blade.php Adds @csrf to goalcanvas edit item POST form.
app/Domain/Goalcanvas/Templates/bigRockDialog.blade.php Adds @csrf to big rock POST form.
app/Domain/Dashboard/Templates/show.blade.php Adds @csrf to dashboard comment POST form.
app/Domain/Connector/Templates/integrationFields.blade.php Adds @csrf to connector integration POST form (currently broken by @csrfid insertion).
app/Domain/Connector/Templates/integrationEntity.blade.php Adds @csrf to connector integration POST form (currently broken by @csrfid insertion).
app/Domain/Comments/Templates/submodules/generalComment.blade.php Adds @csrf to general comment POST form.
app/Domain/Comments/Templates/showAll.blade.php Adds @csrf to comments POST form.
app/Domain/Clients/Templates/newClient.blade.php Adds @csrf to new-client POST form.
app/Domain/Clients/Templates/editClient.blade.php Adds @csrf to edit-client POST form.
app/Domain/Clients/Templates/delClient.blade.php Adds @csrf to delete-client POST form.
app/Domain/Canvas/Templates/delCanvasItem.blade.php Adds @csrf to canvas delete item POST form.
app/Domain/Canvas/Templates/delCanvas.blade.php Adds @csrf to canvas delete board POST form.
app/Domain/Canvas/Templates/canvasDialog.blade.php Adds @csrf to canvas item edit POST form.
app/Domain/Canvas/Templates/boardDialog.blade.php Adds @csrf to board dialog POST form.
app/Domain/Calendar/Templates/showAllGCals.blade.php Adds @csrf to calendar search POST form.
app/Domain/Calendar/Templates/importGCal.blade.php Adds @csrf to calendar import POST form.
app/Domain/Calendar/Templates/export.blade.php Adds @csrf to calendar export POST form.
app/Domain/Calendar/Templates/editExternalCalendar.blade.php Adds @csrf to external calendar edit POST form.
app/Domain/Calendar/Templates/editEvent.blade.php Adds @csrf to event edit POST form.
app/Domain/Calendar/Templates/delExternalCal.blade.php Adds @csrf to external calendar delete POST form.
app/Domain/Calendar/Templates/delEvent.blade.php Adds @csrf to event delete POST form.
app/Domain/Blueprints/Templates/delCanvasItem.blade.php Adds @csrf to blueprints delete item POST form.
app/Domain/Blueprints/Templates/delCanvas.blade.php Adds @csrf to blueprints delete board POST form.
app/Domain/Blueprints/Templates/canvasDialog.blade.php Adds @csrf to blueprints edit item POST form.
app/Domain/Blueprints/Templates/boardDialog.blade.php Adds @csrf to blueprints board dialog POST form.
app/Domain/Auth/Templates/userInvite5.blade.php Adds @csrf to invite flow POST form.
app/Domain/Auth/Templates/userInvite4.blade.php Adds @csrf to invite flow POST form.
app/Domain/Auth/Templates/userInvite3.blade.php Adds @csrf to invite flow POST form.
app/Domain/Auth/Templates/userInvite2.blade.php Adds @csrf to invite flow POST form.
app/Domain/Auth/Templates/userInvite.blade.php Adds @csrf to invite flow POST form.
app/Domain/Auth/Templates/resetPw.blade.php Adds @csrf to password reset POST form.
app/Domain/Auth/Templates/requestPwLink.blade.php Adds @csrf to password reset link request POST form.
app/Domain/Api/Templates/newAPIKey.blade.php Adds @csrf to create API key POST form.
app/Domain/Api/Templates/delKey.blade.php Adds @csrf to delete API key POST form.
app/Domain/Api/Templates/apiKey.blade.php Adds @csrf to API key edit POST form.
app/Core/Http/HttpKernel.php Enables CSRF verification middleware globally.

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

Comment on lines +255 to +256
<form method="post" action="{{ BASE_URL }}/wiki/show/{{ $currentArticle->
@csrfid }}#comment">
Comment on lines +7 to +8
<form method="post" action="{{ BASE_URL }}/wiki/delWiki/{{ $tpl->
@csrfescape($_GET['id']) }}">
Comment on lines +17 to +18
<form method="post" action="{{ BASE_URL }}/ideas/delCanvas/{{ $tpl->
@csrfescape($_GET['id']) }}">
Comment on lines +8 to +9
<form class="formModal" method="post" action="{{ BASE_URL }}/setting/editBoxLabel?{{ http_build_query(['module' =>
@csrf request()->query('module', ''), 'label' => request()->query('label', '')]) }}">
Comment on lines +41 to +42
<form method="post" action="{{ BASE_URL }}/connector/integration/?provider={{ $provider->
@csrfid }}&step=parse{{ $urlAppend }}">
Comment on lines +8 to +9
<form method="post" action="{{ BASE_URL }}/tickets/moveTicket/{{ $ticket->
@csrfid }}" class="formModal">
Comment on lines +26 to +27
<form class="formModal" method="post" action="{{ BASE_URL }}/tickets/editMilestone/{{ $currentMilestone->
@csrfid }}" style="min-width: 250px;">
Comment on lines +8 to +9
<form method="post" action="{{ BASE_URL }}/tickets/delTicket/{{ $ticket->
@csrfid }}">
Comment on lines +3 to +4
<form method="post" action="{{ BASE_URL }}/tickets/delMilestone/{{ $ticket->
@csrfid }}">
Comment thread app/Core/Http/HttpKernel.php Outdated
Comment on lines +62 to +63
// CSRF protection enabled globally — all Blade forms now include @csrf.
\Leantime\Core\Middleware\VerifyCsrfToken::class,
Copilot AI review requested due to automatic review settings July 20, 2026 09:05
@javokhir-sec
javokhir-sec force-pushed the fix/csrf-global-enable branch from 9d1cfed to 5606bb5 Compare July 20, 2026 09:05

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 93 out of 93 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

app/Core/Http/HttpKernel.php:64

  • Enabling VerifyCsrfToken here won’t actually protect any POST routes under /api/* because the middleware’s $except list currently excludes api/*. That means sensitive admin UI actions like /api/newApiKey (and potentially JSON-RPC when a user is authenticated via a normal web session) remain CSRF-bypassable, which undermines the stated goal of global CSRF protection.

Recommendation: narrow the CSRF exception to the specific token-auth endpoints that truly must be exempt (e.g. only api/jsonrpc*, and/or conditionally bypass CSRF only when an X-API-KEY/Bearer token is present), but keep CSRF enforced for browser-session-driven /api/... UI forms.

        // CSRF protection enabled globally — all Blade forms now include @csrf.
        \Leantime\Core\Middleware\VerifyCsrfToken::class,

@marcelfolaron

Copy link
Copy Markdown
Collaborator

Status: needs-decision · Priority: P1 · Next action: human review of the tokenization approach · Owner: @marcelfolaron / @broskees

Picked up in the daily sweep — new since the last pass (opened today). This is the re-do of the closed #3659, and it directly answers the blocker you gave when you closed that one:

"We currently don't have full coverage of the csrf token in the blade components... This requires a more dedicated approach to adding the token html to all blade forms."

#3670 claims exactly that: @csrf added to 92 Blade POST forms across Auth/Calendar/Clients/Comments/Connector/Files/Goalcanvas/Ideas/Ldap/Projects/Tickets/Timesheets/Users/Widgets/Wiki/Setting, plus VerifyCsrfToken uncommented globally in HttpKernel.php, with api/*, cron/*, webhook/*, and install* excluded. 93 files changed (+112/-3).

Not auto-fixing — this is security-sensitive and architecturally broad (every state-changing POST path), so it's a human decision per the security-first rule. Leaving it P1: the vuln is real (CVSS 8.8) but the risk here is regression breadth, not the fix being wrong.

What the review needs to confirm before merge (the "did we actually cover everything" gate):

  1. No untokenized POST form remains — the failure mode is a 419 on a real form in production. Worth a grep for <form ... method="post" / hx-post / .ajaxSubmit( that resolves to a POST and cross-checking each has @csrf or rides the hx-headers X-CSRF-TOKEN path. HTMX and the jQuery-form-plugin paths (meta tag) should be spot-checked live, not assumed.
  2. The exclusion list is complete and correct — confirm nothing user-facing and stateful (e.g. any non-api/* JSON endpoint, file upload, or SSO callback) is left unprotected or wrongly forced through CSRF and broken.
  3. CI is currently blocked/red on the tip (5606bb5) — 16 review threads open. Green Pint + PHPStan L5 + php -l + full acceptance suite (the acceptance run is the one that actually exercises form POSTs end-to-end and would catch a missed token) is the merge gate here, not just unit.

Given the blast radius, a staged rollout thought: this is the kind of change worth landing right after a release cut, not right before, so any missed form surfaces with maximum runway. Advisory only — merge stays with you and @broskees.

javokhir-sec and others added 5 commits July 21, 2026 17:55
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>
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>
…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>
TemplateRegistry::getDatabaseType() returns slug + 'canvas'.
The test XML had key='lean' but the template returns 'leancanvas',
causing import() to fail at the canvas key check before reaching
the path validation assertion.

Changed key from 'lean' to 'leancanvas' and element key to match.

Fixes CI unit test failure on PR Leantime#3656.

Co-Authored-By: Claude <noreply@anthropic.com>
The lean canvas template defines boxes: problem, alternatives, solution,
keymetrics, uniquevalue, etc. Not lean_hypothesis or leancanvas_hypothesis.

Canvas key 'leancanvas' is correct (getDatabaseType() = slug + 'canvas').
Element key must match a real box from the template YAML definition.

Fixes CI unit test failure on PR Leantime#3656.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 21, 2026 12:55
@javokhir-sec
javokhir-sec force-pushed the fix/csrf-global-enable branch from 5606bb5 to 20823a9 Compare July 21, 2026 12:55

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 9 comments.

Comment on lines +405 to +415
// 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;
}
Comment on lines +399 to +403
$allowedDirs = [
sys_get_temp_dir(),
storage_path('userfiles'),
APP_ROOT . '/app/Domain/Blueprints/imports',
];
Comment on lines +420 to +430
foreach ($allowedDirs as $allowedDir) {
$resolvedAllowed = realpath($allowedDir);

if ($resolvedAllowed === false) {
continue;
}

if (str_starts_with($resolvedPath, $resolvedAllowed . DIRECTORY_SEPARATOR)) {
return true;
}
}
Comment on lines +385 to +387
* 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 +477 to 493
$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;
}
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 +539 to +552
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 +473 to +477
// 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);
Copilot AI review requested due to automatic review settings July 21, 2026 13:01

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 1 comment.

Comments suppressed due to low confidence (3)

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:671

  • tempnam(...).'.xml' leaves behind the original tempnam-created file (without the .xml suffix). This can accumulate temp files and make the test non-hermetic; create the temp file first, then rename it to include the extension.
        $tempFile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
        file_put_contents($tempFile, $xml);

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:587

  • This test uses a fixed sibling directory name (sys_get_temp_dir().'-evil'). If it already exists (left over from a prior run or created by something else), the test can overwrite/delete unexpected files or fail on cleanup. Use a unique directory name and assert that mkdir/file creation succeeded.
        $siblingDir = sys_get_temp_dir().'-evil';
        if (! is_dir($siblingDir)) {
            mkdir($siblingDir, 0700, true);
        }
        $siblingFile = $siblingDir.'/blueprint.json';
        file_put_contents($siblingFile, '{}');

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

  • PR title/description are about enabling global CSRF protection, but the changes in this PR are import() path-validation hardening for Blueprints. Either the PR metadata needs updating, or the CSRF-related changes are missing from this PR branch.
    /**
     * Import a canvas board from an XML file.
     *
     * Parses the XML, validates its structure, then creates a new canvas board
     * with all items from the file.

Comment on lines +399 to +403
$allowedDirs = [
sys_get_temp_dir(),
storage_path('userfiles'),
APP_ROOT.'/app/Domain/Blueprints/imports',
];
Copilot AI review requested due to automatic review settings July 21, 2026 13:18

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 no new comments.

Comments suppressed due to low confidence (5)

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

  • The docblock claims that requiring callers to pass a realpath()-resolved path "avoids a TOCTOU window". realpath() normalizes the path (resolves symlinks/..), but it does not eliminate TOCTOU between validation and the subsequent read. Reword to describe normalization rather than TOCTOU prevention.
     * 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:403

  • APP_ROOT.'/app/Domain/Blueprints/imports' does not exist in this repo checkout, so this allow-list entry will always be skipped (realpath() returns false). Also, import() only parses XML (DOMDocument::loadXML), so allowing .json is misleading and makes tests/comments inconsistent. Consider restricting to .xml (and existing dirs) unless JSON import + an imports directory are actually added.
        $allowedDirs = [
            sys_get_temp_dir(),
            storage_path('userfiles'),
            APP_ROOT.'/app/Domain/Blueprints/imports',
        ];

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

  • This PR is titled/described as enabling global CSRF protection and adding @csrf across Blade templates, but the actual changes here are Blueprints import path validation + tests. Either the CSRF changes are missing from the PR, or the PR title/description needs updating so reviewers know what is being shipped.
    /**
     * Import a canvas board from an XML file.
     *
     * Parses the XML, validates its structure, then creates a new canvas board
     * with all items from the file.
     *
     * @param  string  $filename  Path to the XML file

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:671

  • tempnam(...).'.xml' creates (and leaks) an extra temp file without the .xml extension because tempnam() creates the file on disk. Rename the tempnam() result to the .xml path before writing so cleanup is correct and the temp directory doesn't accumulate stray files.
        $tempFile = tempnam(sys_get_temp_dir(), 'leantime.').'.xml';
        file_put_contents($tempFile, $xml);

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:586

  • This test uses a fixed sibling directory name (sys_get_temp_dir().'-evil'). If that directory already exists (e.g., from a previous failed run), cleanup can fail and the test becomes flaky. Use a unique directory name each run; also consider using an allowed extension (e.g. .xml) so the test continues to validate the directory-prefix check even if the service later restricts extensions.
        $siblingDir = sys_get_temp_dir().'-evil';
        if (! is_dir($siblingDir)) {
            mkdir($siblingDir, 0700, true);
        }
        $siblingFile = $siblingDir.'/blueprint.json';

Copilot AI review requested due to automatic review settings July 21, 2026 13:26

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 1 comment.

Comments suppressed due to low confidence (5)

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:587

  • This sibling-prefix bypass test creates a fixed directory name (sys_get_temp_dir().'-evil'), which can collide across parallel runs or leave leftovers that make rmdir() fail. Also, if import path validation is tightened to only allow .xml, the test should use a .xml filename to ensure it fails for the intended directory-prefix reason.
    {
        // 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()

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

  • The docblock claims realpath() usage “avoids a TOCTOU window”, but realpath() primarily canonicalizes the path; it does not eliminate time-of-check/time-of-use races between validation and reading. Rewording avoids giving a false security guarantee.
     * 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:403

  • The allow-list includes APP_ROOT.'/app/Domain/Blueprints/imports', but that directory does not exist in this repo and there are no call sites importing from it. Keeping a non-existent path (and documenting “shipped fixtures”) is misleading; consider removing it or pointing at a real fixtures directory.
        $allowedDirs = [
            sys_get_temp_dir(),
            base_path('userfiles'),
            APP_ROOT.'/app/Domain/Blueprints/imports',
        ];

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

  • import() parses the file as XML (DOMDocument::loadXML), but isImportPathAllowed currently allows '.json'. Since there are no .json import call sites and JSON would never successfully parse here, restricting to '.xml' avoids confusion and reduces the attack surface of readable file types.
        // 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)) {

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

  • PR title/description mention enabling global CSRF middleware and tokenizing Blade forms, but the changes in this PR are focused on Blueprints import path validation/tests. If CSRF changes are intended, they appear to be missing (e.g., HttpKernel still has VerifyCsrfToken commented); otherwise, please update the PR metadata to match the actual changes.
    /**
     * Import a canvas board from an XML file.
     *
     * Parses the XML, validates its structure, then creates a new canvas board
     * with all items from the file.
     *
     * @param  string  $filename  Path to the XML file
     * @param  string  $canvasSlug  Canvas type slug (e.g., "swot", "lean")

Comment on lines 40 to 42
$template = new CanvasTemplate([
'slug' => 'swot',
'slug' => 'lean',
'boxes' => ['swot_strengths' => ['icon' => 'fa-x', 'title' => 'box.swot.strengths']],
Copilot AI review requested due to automatic review settings July 21, 2026 13:32

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 1 comment.

Comments suppressed due to low confidence (6)

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

  • The phpDoc claims resolving via realpath() “avoids a TOCTOU window”, but realpath() only canonicalizes the path (resolves symlinks/..). There is still a time gap between validation and file_get_contents(); the comment should describe canonicalization rather than TOCTOU avoidance.
     * 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:403

  • The allow-list includes APP_ROOT.'/app/Domain/Blueprints/imports', but that directory does not exist in the repository (so it will always be skipped by realpath()). Either create/use the correct fixture directory or remove this entry to avoid dead/misleading configuration.
        $allowedDirs = [
            sys_get_temp_dir(),
            base_path('userfiles'),
            APP_ROOT.'/app/Domain/Blueprints/imports',
        ];

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

  • import() only supports XML (it always uses DOMDocument::loadXML), but the extension allow-list permits JSON and comments reference JSON fixtures. This is misleading and allows an extension that can never successfully import. Restrict the allow-list to xml (or add JSON parsing support).
        // 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)) {

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

  • PR title/description focus on enabling global CSRF protection, but the changes in this PR (and current tree) don’t appear to enable VerifyCsrfToken in the HTTP kernel (it remains commented out). Either include the missing CSRF-related changes or update the PR metadata to match the actual scope.
        // 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);

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

  • This docblock says the allow-list covers “three places” including a shipped fixture directory, but the implementation doesn’t have a Blueprints imports fixture directory and the suggested fix removes that entry. Update the comment so it reflects the actual allowed sources.
     * 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.
     *

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:543

  • This test comment references a Blueprints/imports allow-list entry, but the production allow-list doesn’t include a shipped imports directory (and the suggested fix removes that path). Update the comment so it matches the actual allow-list.
        // 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(

Comment on lines +590 to +596
// 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, '{}');
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Status: needs-decision · Priority: P1 · Next action: human review of the tokenization approach + run the full test/lint suite on this head · Owner: @marcelfolaron / @broskees

Automated maintainer re-review (advisory only — not an approval; merge authority stays with @marcelfolaron / @broskees). Head advanced to 5fceadd7 since my 07-20 note (5606bb5). Re-checked the new-commit chain.

1. Intent (unchanged): Completes CWE-352 — @csrf on 92 Blade POST forms + VerifyCsrfToken enabled globally in HttpKernel.php, with api/*, cron/*, webhook/*, install* excluded. This is the re-do of the closed #3659, answering the "no full CSRF token coverage across blade forms" blocker.

2. What changed since last review: the new commits on this branch (fix/pint-csrf-final) are Pint formatting + a merge of fix/ssrf-lfi-final (#3656) — not changes to the CSRF tokenization itself. So the core CSRF change is materially the same as reviewed; the P1 nature is unchanged: the risk is regression breadth (a missed form → 419 in prod), not the fix being wrong. Note the merged-in SSRF/LFI history means this branch now carries #3656's changes too — untangle before merge so the two land cleanly and independently (this PR shouldn't silently also merge #3656).

3. What the human review must confirm before merge (the "did we cover everything" gate):

  1. No untokenized POST form remains. Grep for <form … method="post", hx-post, and .ajaxSubmit(/jQuery-form-plugin POSTs; each must carry @csrf or ride the hx-headers X-CSRF-TOKEN (HTMX) / meta-tag (jQuery) path. Spot-check HTMX and jQuery-plugin submissions live, not by assumption.
  2. Exclusion list is complete and correct — nothing user-facing and stateful left unprotected (non-api/* JSON endpoints, file uploads, SSO callbacks), and nothing wrongly forced through CSRF and broken.
  3. File-upload + multipart forms specifically — confirm the token survives multipart encoding on the file/import paths.

4. Acceptance checklist (must pass before merge):

  1. CI green on 5fceadd7: Pint + PHPStan L5 + full acceptance suite — the acceptance run is the one that exercises form POSTs end-to-end and would catch a missed token; unit alone is insufficient here (see §5).
  2. Manual pass: representative POST from each affected domain (Auth, Tickets, Timesheets, Projects, Files, Wiki, Settings…) succeeds, and one deliberately-stripped token returns 419 (proving enforcement is actually on).
  3. The fix: validate file path in Blueprints::import() to prevent SSRF/LFI (CWE-918) #3656 merge is untangled so this PR lands only the CSRF change (or the two are sequenced deliberately).

5. CI status — ⚠ HIGH PRIORITY: Combined status on 5fceadd7 shows only license/cla (success) — no Pint, no PHPStan, no acceptance/unit suite has reported. For a change whose entire risk profile is regression breadth across every state-changing POST, merging without the acceptance suite green on this exact head would be flying blind. Do not merge until it runs green here.

Rollout: given the blast radius, land this right after a release cut, not right before, so any missed form surfaces with maximum runway.

Advisory only — not an approval, not marking ready, not merging. Merge authority stays with @marcelfolaron / @broskees.

The 3.6MB composer.phar binary was accidentally committed.
Removed from tracking and added to .gitignore to prevent recurrence.

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

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 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (4)

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

  • The docstring claims that resolving with realpath() “avoids a TOCTOU window between resolution and validation”, but realpath() mainly canonicalizes the path (resolves symlinks/..). The current wording is misleading for future maintainers; consider rephrasing to describe canonicalization/allow-list validation instead.
     * 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:403

  • The allow-list includes APP_ROOT.'/app/Domain/Blueprints/imports', but that directory does not exist in the repository. As written, it will always resolve to false and never allow anything, and the surrounding comments imply a fixture directory that isn’t present.
        $allowedDirs = [
            sys_get_temp_dir(),
            base_path('userfiles'),
            APP_ROOT.'/app/Domain/Blueprints/imports',
        ];

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

  • import() only parses XML via DOMDocument::loadXML(), but isImportPathAllowed() currently allows both xml and json. Allowing json is inconsistent with the method’s behavior and broadens the readable surface area unnecessarily; it should be restricted to xml unless JSON imports are actually supported.
        // 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)) {

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:596

  • This test uses a .json file for the sibling-prefix bypass case. If the service is (correctly) restricted to .xml imports, the failure could be caused by extension validation rather than the directory-prefix check, making the test less meaningful. Use a .xml filename so the test specifically exercises the prefix/allow-list logic.
        $siblingFile = $siblingDir.'/blueprint.json';
        file_put_contents($siblingFile, '{}');

Comment on lines +548 to +549
$outOfBounds = '/var/tmp/leantime_lfi_test_' . uniqid('', true) . '.xml';
file_put_contents($outOfBounds, '<canvas key="leancanvas"><title>LFI Test</title></canvas>');
Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 04:38
The test comment mentioned guarding against file:///etc/passwd but only
asserted http:// and ftp://. Add an explicit file:// assertion to match
the stated intent.

Co-Authored-By: Claude <noreply@anthropic.com>

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 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

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

  • import() parses XML via DOMDocument, but isImportPathAllowed() currently allows .json files and the comment mentions JSON fixtures. Since JSON cannot be imported here, allowing it is unnecessary and increases the readable-surface within allowed directories. Restrict the allow-list to .xml to match the method contract and tests.
        // 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', [

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

  • The phpDoc claims that resolving with realpath() “avoids a TOCTOU window”, but the code still validates then later reads by path, so TOCTOU is still possible; realpath() mainly canonicalizes the path for traversal checks. Adjust the wording to avoid stating an incorrect guarantee.
     * requires a known extension. The caller must resolve the path via
     * {@see realpath()} first — this avoids a TOCTOU window between
     * resolution and validation.

tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php:552

  • This test writes to /var/tmp, which is not guaranteed to exist or be writable across all CI/container environments, making it flaky. Use a path that is guaranteed to exist and be writable during tests (but still outside the service allow-list), e.g. the current test directory.
        // 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, '<canvas key="leancanvas"><title>LFI Test</title></canvas>');

- TOCTOU docstring, XML-only extension, userfiles removal
- is_file()/is_readable() guard, unique temp filenames/dirs
- file:// assertion, <data>/<conclusion> XML elements
- slug/boxes consistency

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

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 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

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

  • This PR is described as a global CSRF enablement change, but this hunk also introduces SSRF/LFI/path-traversal hardening for Blueprints import() plus related logging. To avoid reviewers missing security-relevant scope, the PR description should mention this additional change explicitly (or split into a separate PR).
    /**
     * 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

Comment on lines +670 to +677
$expectedId = 42;
$repo = $this->make(BlueprintsRepository::class, [
'existCanvas' => fn () => false,
'addCanvas' => fn () => $expectedId,
'addCanvasItem' => fn () => 1,
]);
$service = $this->securedService($repo, $this->allowingPermissions());

javokhir-sec and others added 2 commits July 23, 2026 09:58
- Reword temp dir docblock to be precise about world-writable /tmp risk
- Restore app/Domain/Blueprints/imports/.gitkeep that was lost during
  branch updates — this directory must exist so realpath() can resolve
  it and shipped fixture imports can pass the allow-list check.

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

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 4 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

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

  • The PR title/description focuses on enabling CSRF globally (adding @csrf + enabling VerifyCsrfToken), but the actual changes in this PR are centered on Blueprints import path validation (SSRF/LFI) plus a .gitignore update. Also, VerifyCsrfToken still appears commented out in app/Core/Http/HttpKernel.php:64, so CSRF does not look enabled as described. Please either update the PR metadata to match the shipped changes, or include the intended CSRF middleware + template updates in this PR.
        // 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 486 to 488
$dom = new DOMDocument('1.0', 'UTF-8');
$users = app()->make(UserRepository::class);

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.

3 participants