Release 2.58 (#5952)

* bump version
* fix formatting locale reset after embedded controller sub-requests (#5944)
* fix GHSA-c6w6-57jj-62vh
* fix GHSA-m492-gv72-xvxj
* fix GHSA-jr9p-4h4j-6c58
* make sure to only use JS logic to call API endpoints
* fixes GHSA-r8vr-m544-qh4h
* make sure to only use JS logic to call API endpoints
* fix GHSA-rw46-qg69-vg6h
* fix GHSA-pj8j-p4g4-4vw8 - prevent kimai from rendering images via markdown
* fix GHSA-pj8j-p4g4-4vw8 - use a safe network client to prevent SSRF via images
* fix GHSA-xv4r-4885-gwpg
* fix GHSA-pgcc-vfmc-7cw5 - move GET routes to API with POST method to prevent CSRF
* fix tooltip survives page reload
* updated wizard images
* split wizard and password reset subscriber into two classes
* relax upper php limit
* added zizmor workflow scans and apply findings
* user permissions <name>_other_profile  now respect teams
* move all linting steps to new job
* updated docker image version names
* use .env.local for storing APP_SECRET
* improve build order and use given tag as ref for checkout, not default main branch
* improved APP_SECRET handling, see entrypoint.sh
* use local code for building the image for more flexibility, added dockerignore
This commit is contained in:
Kevin Papst
2026-05-25 15:39:47 +02:00
committed by GitHub
parent 8d245ae223
commit 31a8f887a5
85 changed files with 2737 additions and 472 deletions

View File

@@ -25,11 +25,11 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
$timesheet = $payload['timesheet'];
if ($timesheet->getId() !== null) {
if ($timesheet->isRunning() && $this->isGranted('stop', $timesheet)) {
$event->addAction('stop', ['url' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
$event->addAction('stop', ['url' => '#', 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-href' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
}
if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) {
$event->addAction('repeat', ['title' => 'repeat', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
$event->addAction('repeat', ['title' => 'repeat', 'url' => '#', 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-href' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
}
if ($this->isGranted('edit', $timesheet)) {

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\EventSubscriber;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
class PasswordResetSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly AuthorizationCheckerInterface $security,
private readonly TokenStorageInterface $storage,
) {
}
public static function getSubscribedEvents(): array
{
return [
// higher priority is executed earlier - need to be higher than wizard
KernelEvents::REQUEST => ['onKernelRequest', -20]
];
}
public function onKernelRequest(RequestEvent $event): void
{
// ignore sub-requests
if (!$event->isMainRequest() || null === ($token = $this->storage->getToken())) {
return;
}
$uri = $event->getRequest()->getRequestUri();
// never trigger password reset on API calls
// TODO 3.0 remove /register/
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
return;
}
$user = $token->getUser();
if (!($user instanceof User)) {
return;
}
if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
return;
}
if (!$user->requiresPasswordReset()) {
return;
}
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
$event->setResponse($response);
}
}

View File

@@ -10,11 +10,13 @@
namespace App\EventSubscriber;
use App\Configuration\LocaleService;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* When visiting the homepage, this listener redirects the user to the most
@@ -24,7 +26,8 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LocaleService $localeService
private readonly LocaleService $localeService,
private readonly TokenStorageInterface $storage,
)
{
}
@@ -32,7 +35,9 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest']
// the higher the priority (default: 0), the earlier it is executed
// runs on default priority to make sure we have the correct locale in the URL
KernelEvents::REQUEST => ['onKernelRequest', 0]
];
}
@@ -52,15 +57,26 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
return;
}
$allLanguages = $this->localeService->getTranslatedLocales();
$preferredLanguage = null;
// Add the default locale at the first position of the array, because getPreferredLanguage()
// returns the first element when no appropriate language is found
array_unshift($allLanguages, 'en');
if (null !== ($token = $this->storage->getToken())) {
$user = $token->getUser();
if ($user instanceof User) {
$preferredLanguage = $user->getLanguage();
}
}
$preferredLanguage = $request->getPreferredLanguage(array_unique($allLanguages));
if ($preferredLanguage === null){
$allLanguages = $this->localeService->getTranslatedLocales();
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage]));
// Add the default locale at the first position of the array, because getPreferredLanguage()
// returns the first element when no appropriate language is found
array_unshift($allLanguages, 'en');
$preferredLanguage = $request->getPreferredLanguage(array_unique($allLanguages));
}
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage ?? 'en']));
$event->setResponse($response);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\EventSubscriber;
use App\Entity\User;
use App\Twig\LocaleFormatExtensions;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
@@ -19,6 +20,8 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class UserEnvironmentSubscriber implements EventSubscriberInterface
{
private ?string $userLocale = null;
public function __construct(
private readonly TokenStorageInterface $tokenStorage,
private readonly AuthorizationCheckerInterface $auth,
@@ -30,10 +33,30 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['prepareEnvironment', -100],
// runs as first one in Kimai, to make sure we use the correct locales for rendering
KernelEvents::REQUEST => ['prepareEnvironment', -10],
// don't know why do we use -20
KernelEvents::FINISH_REQUEST => ['restoreLocale', -20],
];
}
public function restoreLocale(FinishRequestEvent $event): void
{
if ($event->isMainRequest()) {
return;
}
if ($this->userLocale === null) {
return;
}
// LocaleSwitcher (called by LocaleAwareListener) overwrites \Locale::getDefault() with the URL
// locale during sub-requests. Restore both the PHP default and the Twig formatter locale to
// the user's formatting locale that was saved during the main request.
\Locale::setDefault($this->userLocale);
$this->localeFormatExtensions->setLocale($this->userLocale);
}
public function prepareEnvironment(RequestEvent $event): void
{
// ignore sub-requests
@@ -55,6 +78,7 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
}
// the locale is primarily used for formatting values, so we depend on the user locale if available
$this->userLocale = $locale;
\Locale::setDefault($locale);
$this->localeFormatExtensions->setLocale($locale);
}

View File

@@ -22,35 +22,31 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
class WizardSubscriber implements EventSubscriberInterface
{
public function __construct(
private UrlGeneratorInterface $urlGenerator,
private AuthorizationCheckerInterface $security,
private TokenStorageInterface $storage,
private SystemConfiguration $systemConfiguration
private readonly UrlGeneratorInterface $urlGenerator,
private readonly AuthorizationCheckerInterface $security,
private readonly TokenStorageInterface $storage,
private readonly SystemConfiguration $systemConfiguration
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest']
KernelEvents::REQUEST => ['onKernelRequest', -30]
];
}
public function onKernelRequest(RequestEvent $event): void
{
// ignore sub-requests
if (!$event->isMainRequest()) {
return;
}
// ignore events like the toolbar where we do not have a token
if (null === ($token = $this->storage->getToken())) {
// ignore sub-requests and un-authenticated events
if (!$event->isMainRequest() || null === ($token = $this->storage->getToken())) {
return;
}
$uri = $event->getRequest()->getRequestUri();
// never require 2FA on API calls
// never trigger wizard on API calls
// TODO 3.0 remove /register/
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
return;
}
@@ -65,11 +61,6 @@ class WizardSubscriber implements EventSubscriberInterface
return;
}
if ($user->requiresPasswordReset()) {
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
$event->setResponse($response);
}
if ($user->isRegularUserOnly() && !$this->systemConfiguration->isUserWizardActive()) {
return;
}