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

@@ -83,6 +83,61 @@ function prepareKimai() {
echo "Kimai is ready"
}
function ensureAppSecret() {
# GHSA-jr9p-4h4j-6c58
# Make sure the container never runs with the publicly-known default APP_SECRET.
# If the user provided their own value (via -e APP_SECRET=...) it is kept untouched.
# Otherwise a unique secret is generated once and persisted below var/data, which
# is the directory mounted as a named volume in the documented Docker setup, so it
# stays stable across container restarts and re-creations.
#
# Disable xtrace around all reads/writes of APP_SECRET so the secret never appears
# in container logs. The braces around `set +x` keep the disable command itself
# from being traced.
{ set +x; } 2>/dev/null
local SECRET_FILE=/opt/kimai/var/data/.appsecret
local ENV_LOCAL=/opt/kimai/.env.local
# Always remove any prior .env.local before deciding which secret applies.
# This prevents a stale auto-generated value from lingering after a user
# later sets APP_SECRET via docker env / compose. It is regenerated below
# in the auto-secret path; in the user-provided path it stays absent so
# the real env var remains the single source of truth.
rm -f "$ENV_LOCAL"
if [ -n "$APP_SECRET" ] && [ "$APP_SECRET" != "change_this_to_something_unique" ]; then
set -x
return
fi
if [ -s "$SECRET_FILE" ]; then
APP_SECRET=$(cat "$SECRET_FILE")
echo "APP_SECRET: using persisted auto-generated secret"
else
mkdir -p "$(dirname "$SECRET_FILE")"
APP_SECRET=$(php -r 'echo bin2hex(random_bytes(32));')
( umask 077 && echo "$APP_SECRET" > "$SECRET_FILE" )
chown "$USER_ID:$GROUP_ID" "$SECRET_FILE"
echo "APP_SECRET: generated a new unique secret, persisted to var/data volume"
fi
export APP_SECRET
# Mirror the resolved secret into .env.local so Symfony's Dotenv picks up
# the right value when commands are run via `docker exec` (which does not
# inherit the entrypoint's exported env). .env.local is Symfony's official
# override file and is loaded before .env. Rewritten on every container
# start; the source of truth is the persisted SECRET_FILE above.
( umask 077 && echo "APP_SECRET=$APP_SECRET" > "$ENV_LOCAL" )
# The PHP runtime (apache/php-fpm) runs as $USER_ID:$GROUP_ID and must be
# able to read .env.local; the entrypoint itself runs as root, so the file
# would otherwise be 0600 root:root and unreadable to the web user, causing
# Symfony's Dotenv to throw PathException at boot.
chown "$USER_ID:$GROUP_ID" "$ENV_LOCAL"
set -x
}
function runServer() {
# Just while I'm fixing things
/opt/kimai/bin/console kimai:reload --env="$APP_ENV"
@@ -98,5 +153,6 @@ function runServer() {
waitForDB
handleStartup
ensureAppSecret
prepareKimai
runServer

76
.dockerignore Normal file
View File

@@ -0,0 +1,76 @@
# Secrets and local environment
.env
.env.*
!.env.dist
.dockerhub.secrets
config/secrets/*
# Git and CI
.git
.gitattributes
.gitignore
.github
# Build artifacts and dependencies (installed inside the image)
vendor/
node_modules/
public/bundles/
.pnpm-store/
.yarn/
.yarnrc.yml
.pnp.*
# Symfony runtime data — exclude contents but keep the directories themselves
# (the dirs are tracked in git via .gitkeep and code expects them to exist)
var/advisories/
var/cache/*
var/log/*
var/sessions/*
var/data/*
var/dev/*
var/invoices/*
var/export/*
var/packages/*
var/plugins/*
var/templates/*
!var/**/.gitkeep
# IDE, tools, caches
.idea/
.claude/
.vscode/
.php-cs-fixer.cache
.php_cs.cache
.phpunit.result.cache
.phpunit/
nbproject/
# Dev-only configs
test/
.php-cs-fixer.dist.php
php-cs-fixer.sh
phpstan.sh
phpstan.neon
phpstan-baseline.neon
eslint.config.mjs
.editorconfig
.codecov.yml
phpunit.xml
phpunit.xml.dist
# Docker and compose (not needed inside the image; .docker/ is copied explicitly)
docker-compose.yml
docker-compose.*.yml
Dockerfile
.dockerignore
# OS junk
.DS_Store
Thumbs.db
# Local Symfony overrides
public/.user.ini
php.ini
.php-version
config/packages/local.yaml
config/bundles-local.php

View File

@@ -9,32 +9,28 @@ on:
release:
types: [released]
concurrency:
group: docker-build
cancel-in-progress: true
permissions: {}
jobs:
build:
name: Docker images
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install buildx
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
- name: Determine version
env:
GITHUB_EVENT_INPUTS_KIMAI_TAG: ${{ github.event.inputs.kimai_tag }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
input="${{ github.event.inputs.kimai_tag }}"
input="${GITHUB_EVENT_INPUTS_KIMAI_TAG}"
# Determine between manual trigger and release event
if [ -z "$input" ]; then
echo "Using release tag: ${{ github.event.release.tag_name }}"
version="${{ github.event.release.tag_name }}"
echo "Using release tag: ${GITHUB_EVENT_RELEASE_TAG_NAME}"
version="${GITHUB_EVENT_RELEASE_TAG_NAME}"
else
echo "Using tag provided: $input"
version="$input"
@@ -47,8 +43,42 @@ jobs:
echo "kimai_version=$version" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ env.kimai_version }}
persist-credentials: false
- name: Install buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to DockerHub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
# TODO 3.0 remove kimai/kimai2:apache and kimai/kimai2:apache-<version>
- name: Apache image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: Dockerfile
build-args: |
KIMAI=${{ env.kimai_version }}
BASE=apache
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:stable
kimai/kimai2:apache
kimai/kimai2:2
kimai/kimai2:${{ env.kimai_version }}
push: true
# TODO 3.0 remove the deprecated FPM build
- name: FPM image
uses: docker/build-push-action@v6
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: Dockerfile
@@ -62,24 +92,8 @@ jobs:
kimai/kimai2:fpm
push: true
- name: Apache image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
build-args: |
KIMAI=${{ env.kimai_version }}
BASE=apache
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:stable
kimai/kimai2:apache
kimai/kimai2:apache-${{ env.kimai_version }}
push: true
- name: Development image
uses: docker/build-push-action@v6
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: Dockerfile

View File

@@ -4,25 +4,30 @@ on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
frontend:
runs-on: ubuntu-latest
name: Frontend verification
steps:
- name: Clone Kimai
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup PNPM
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm

76
.github/workflows/linting.yaml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Lint PHP
on:
pull_request: null
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
integration:
name: Linting (${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2'] # use lowest supported PHP version to prevent introducing syntax from newer PHP versions
steps:
- name: Clone Kimai
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
with:
php-version: ${{ matrix.php }}
coverage: pcov
extensions: ctype, gd, iconv, intl, mbstring, xml, zip
tools: cs2pr, symfony-cli
env:
fail-fast: true
- name: Determine composer cache directory
id: composer-cache
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache Composer dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: "${{ env.composer_cache_directory }}"
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
- name: Install dependencies
run: composer install
- name: Validate Composer
run: composer validate --strict --no-check-all
- name: Lint YAML
run: APP_ENV=dev bin/console lint:yaml --parse-tags config
- name: Lint XLIFF
run: APP_ENV=dev bin/console lint:xliff translations
- name: Check codestyles
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
- name: Run PHPStan for application
run: vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=checkstyle | cs2pr
- name: Run PHPStan for tests
run: vendor/bin/phpstan analyse -c tests/phpstan.neon --no-progress --error-format=checkstyle | cs2pr
- name: Lint codebase
run: composer linting
- name: Check for security issues in packages
run: symfony security:check

View File

@@ -5,20 +5,22 @@ on:
- cron: '17 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
permissions: {}
concurrency:
group: lock-threads
cancel-in-progress: true
jobs:
action:
permissions:
issues: write # needed to comment and close the issues
runs-on: ubuntu-latest
name: Lock inactive issues
steps:
- uses: dessant/lock-threads@v6
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
with:
process-only: 'issues, prs'
process-only: 'issues'
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '90'
issue-comment: >

View File

@@ -1,21 +1,27 @@
name: Check .lock files
on:
on: # zizmor: ignore[dangerous-triggers] using the target branch workflow prevents adding new trustedAuthors
pull_request_target: null
push:
branches:
- main
permissions:
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
lockfiles:
permissions:
pull-requests: read # read the PR author
runs-on: ubuntu-latest
name: Verify lock file integrity
steps:
- name: Prevent file change
uses: xalvarez/prevent-file-change-action@v3
uses: xalvarez/prevent-file-change-action@dbc67c205c9ed02aa39c497b422c24f364c3e8dd # v3.0.1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
pattern: .*\.lock$|^\.github\/.*$
trustedAuthors: kevinpapst, dependabot
trustedAuthors: kevinpapst

View File

@@ -5,26 +5,29 @@ on:
branches:
- main
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
correct_repository:
permissions:
contents: none
runs-on: ubuntu-latest
name: Verify repository
steps:
- name: fail on fork
if: github.repository_owner != 'kimai'
run: exit 1
update_release_draft:
name: Draft next release
permissions:
contents: write # for release-drafter/release-drafter to create a github release
pull-requests: read
contents: write # to create a github release draft
pull-requests: read # to identify changes that occurred
needs: correct_repository
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v7
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
env:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,15 +1,24 @@
name: Tests
on:
pull_request: null
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
integration:
name: Integration (${{ matrix.php }})
runs-on: ubuntu-latest
services:
mysql:
image: mysql:latest
image: mysql@sha256:c36050afdca850f23cef85703f84c7531a5ae155a11b5ee1c60acb09937c4084 # 8.4.9
env:
MYSQL_ALLOW_EMPTY_PASSWORD: false
MYSQL_ROOT_PASSWORD: kimai
@@ -17,20 +26,20 @@ jobs:
ports:
- 3306/tcp
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
name: Integration (${{ matrix.php }})
steps:
- name: Clone Kimai
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
with:
php-version: ${{ matrix.php }}
coverage: pcov
@@ -47,7 +56,7 @@ jobs:
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: "${{ env.composer_cache_directory }}"
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -55,28 +64,11 @@ jobs:
- name: Install dependencies
run: composer install
- name: Validate Composer
run: composer validate --strict --no-check-all
- name: Warmup cache
run: APP_ENV=dev bin/console kimai:reload -n
- name: Check codestyles
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
- name: Run PHPStan for application
run: vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=checkstyle | cs2pr
- name: Run PHPStan for tests
run: vendor/bin/phpstan analyse -c tests/phpstan.neon --no-progress --error-format=checkstyle | cs2pr
- name: Lint codebase
run: composer linting
- name: Install LDAP package (for tests)
run: composer require laminas/laminas-ldap
- name: Setup problem matchers (for PHPUnit)
# zizmor: ignore[template-injection] coming from the https://github.com/marketplace/actions/setup-php-action
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Run quick unit-tests
@@ -104,7 +96,7 @@ jobs:
- name: Upload code coverage
if: matrix.php == '8.5'
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
@@ -120,6 +112,3 @@ jobs:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
APP_ENV: dev
MAILER_URL: null://localhost
- name: Check for security issues in packages
run: symfony security:check

View File

@@ -1,4 +1,5 @@
name: 'Website update'
on:
workflow_dispatch:
inputs:
@@ -8,19 +9,28 @@ on:
release:
types: [released]
concurrency:
group: update-website
cancel-in-progress: true
permissions: {}
jobs:
build:
name: Trigger version update for website
runs-on: ubuntu-latest
steps:
- name: "Determine Version"
env:
GITHUB_EVENT_INPUTS_KIMAI_VERSION: ${{ github.event.inputs.kimai_version }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
input="${{ github.event.inputs.kimai_version }}"
input="${GITHUB_EVENT_INPUTS_KIMAI_VERSION}"
# Determine between manual trigger and release event
if [ -z "$input" ]; then
echo "No input provided, using release tag"
version="${{ github.event.release.tag_name }}"
version="${GITHUB_EVENT_RELEASE_TAG_NAME}"
else
echo "Using input provided: $input"
version="$input"
@@ -34,7 +44,7 @@ jobs:
fi
- name: Emit repository_dispatch
uses: peter-evans/repository-dispatch@v4
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.WEBSITE_ACCESS_TOKEN }}
repository: kimai/www.kimai.org

28
.github/workflows/zizmor.yaml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Actions Security Analysis
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
zizmor:
name: Scan workflows
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6

View File

@@ -24,8 +24,9 @@
# Source base, one of: fpm, apache
ARG BASE="fpm"
# Kimai branch/tag to run
ARG KIMAI="main"
# Kimai version label (used for OCI labels and the KIMAI env var inside the image).
# The actual source is read from the local build context, not fetched by version.
ARG KIMAI="dev"
# Timezone for images
ARG TIMEZONE="Europe/Berlin"
@@ -217,20 +218,6 @@ COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20230831
# PHP extension opcache
COPY --from=php-ext-opcache /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
###########################
# fetch Kimai sources
###########################
FROM alpine:latest AS git-prod
ARG KIMAI
ARG TIMEZONE
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch
RUN [[ $KIMAI =~ ^[0-9] ]] && export REF='tags' || export REF='heads' && \
wget -O "/opt/kimai.tar.gz" "https://github.com/kimai/kimai/archive/refs/${REF}/${KIMAI}.tar.gz" && \
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \
mv /opt/kimai-${KIMAI} /opt/kimai
###########################
# global base build
###########################
@@ -260,7 +247,11 @@ COPY .docker/dbtest.php /dbtest.php
COPY .docker/entrypoint.sh /entrypoint.sh
ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=8.3"
ENV APP_SECRET=change_this_to_something_unique
# APP_SECRET is intentionally not set here. The entrypoint resolves it (user-provided
# via -e APP_SECRET=... wins; otherwise a unique value is generated and persisted to
# the var/data volume and mirrored into .env.local). Setting it as a Dockerfile ENV
# would create a real env var that always wins over .env*, breaking `docker exec`
# console invocations.
# The default container name for nginx is nginx
ENV TRUSTED_PROXIES=nginx,localhost,127.0.0.1
ENV MAILER_FROM=kimai@example.com
@@ -283,15 +274,15 @@ CMD [ "/entrypoint.sh" ]
# development build
FROM base AS dev
# copy kimai develop source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
# copy kimai source from local build context (see .dockerignore for what is excluded)
COPY --chown=www-data:www-data . /opt/kimai
COPY .docker /assets
# do the composer deps installation
RUN \
export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \
composer --no-ansi clearcache && \
composer --no-ansi require --working-dir=/opt/kimai laminas/laminas-ldap && \
composer --no-ansi clearcache && \
cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini && \
chown -R www-data:www-data /opt/kimai /usr/local/etc/php/php.ini && \
mkdir -p /opt/kimai/var/logs && chmod 777 /opt/kimai/var/logs && \
@@ -304,15 +295,15 @@ ENV memory_limit=512M
# the "prod" stage (production build) is configured as last stage in the file, as this is the default target in BuildKit
FROM base AS prod
# copy kimai production source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
# copy kimai source from local build context (see .dockerignore for what is excluded)
COPY --chown=www-data:www-data . /opt/kimai
COPY .docker /assets
# do the composer deps installation
RUN \
export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --no-dev --optimize-autoloader && \
composer --no-ansi clearcache && \
composer --no-ansi require --update-no-dev --working-dir=/opt/kimai laminas/laminas-ldap && \
composer --no-ansi clearcache && \
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \
sed -i "s/expose_php = On/expose_php = Off/g" /usr/local/etc/php/php.ini && \
sed -i "s/;opcache.enable=1/opcache.enable=1/g" /usr/local/etc/php/php.ini && \

View File

@@ -8,6 +8,17 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [2.58.0](https://github.com/kimai/kimai/releases/tag/2.58.0)
The official Docker image no longer runs with the default `APP_SECRET=change_this_to_something_unique`.
If you did not explicitly set your own `APP_SECRET` (via `-e APP_SECRET=...` or your compose/environment
config), the container now generates a unique secret on first start and persists it as `var/data/.appsecret`
inside the `data` volume that the documented Docker setup already mounts.
As a one-time effect of this upgrade, all existing sessions, "remember me" cookies and pending
password-reset links become invalid — every user has to log in again once. No manual action is required;
the container starts as before. It is recommended to configure your own `APP_SECRET` explicitly.
## [2.56.0](https://github.com/kimai/kimai/releases/tag/2.56.0)
The required minimum PHP version is now 8.2, read https://www.php.net/supported-versions.php

View File

@@ -9,6 +9,8 @@
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
*/
import { Tooltip } from 'bootstrap';
export default class KimaiReloadPageWidget {
constructor(events, fullReload) {
@@ -48,8 +50,14 @@ export default class KimaiReloadPageWidget {
response.text().then((text) => {
const temp = document.createElement('div');
temp.innerHTML = text;
const oldContent = document.querySelector('section.content');
// dispose all tooltips before replacing the content, otherwise an open tooltip
// would remain visible in the upper left corner after its anchor element is gone
oldContent.querySelectorAll('[data-toggle="tooltip"]').forEach((el) => {
Tooltip.getInstance(el)?.dispose();
});
const newContent = temp.querySelector('section.content');
document.querySelector('section.content').replaceWith(newContent);
oldContent.replaceWith(newContent);
document.dispatchEvent(new Event('kimai.reloadPage'));
this._hideOverlay();
});

View File

@@ -14,7 +14,7 @@
}
],
"require": {
"php": "8.2.*||8.3.*||8.4.*||8.5.*",
"php": ">=8.2",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",

4
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9408273b8cfa9c509c6e483245818e8e",
"content-hash": "fa13e2255a6ecd636351e12bf7b69792",
"packages": [
{
"name": "azuyalabs/yasumi",
@@ -14691,7 +14691,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "8.2.*||8.3.*||8.4.*||8.5.*",
"php": ">=8.2",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",

View File

@@ -72,7 +72,9 @@ security:
login_link:
check_route: link_login_check
signature_properties: ['id']
# 'password' binds the HMAC signature to the password hash, so a
# link becomes invalid as soon as the user changes the password
signature_properties: ['id', 'password']
lifetime: 900
max_uses: 3

View File

@@ -58,9 +58,23 @@ services:
arguments:
$settings: '%kimai.config%'
# HTTP client used by mPDF to fetch remote resources (e.g. logos referenced
# from custom Twig invoice templates). Decorated with NoPrivateNetworkHttpClient
# so requests to private/loopback/link-local targets are blocked at the
# network layer — defense in depth for SSRF.
# @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
Symfony\Component\HttpClient\NoPrivateNetworkHttpClient:
arguments:
- '@http_client'
App\Pdf\SafeRemoteContentClient:
arguments:
- '@Symfony\Component\HttpClient\NoPrivateNetworkHttpClient'
App\Pdf\MPdfConverter:
arguments:
$cacheDirectory: '%kernel.cache_dir%'
$httpClient: '@App\Pdf\SafeRemoteContentClient'
App\Utils\FileHelper:
arguments:

View File

@@ -3707,16 +3707,6 @@ parameters:
count: 1
path: src/Utils/Parsedown.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) has parameter \\$Excerpt with no type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Parameter \\#1 \\$str of function strtr expects string, string\\|null given\\.$#"
count: 1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.684e9f6d.js",
"/build/app.77b6dec9.js"
"/build/app.4f8430f7.js"
],
"css": [
"/build/app.49955ea2.css"
@@ -81,7 +81,7 @@
},
"integrity": {
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
"/build/app.77b6dec9.js": "sha384-qaaKjV9klY58pqcU0Ynu4jjnhO9kZK2kVQD1lcVS1nQs759cFYIRfXpTXmGYppei",
"/build/app.4f8430f7.js": "sha384-X029h9FgtC24+GvFSqlEVnKRrumwmpyWaD8nkFZlIYfDoV3/43Gnj5A4s88GopVD",
"/build/app.49955ea2.css": "sha384-8ix/CKnR2d1mU3tMEFJPrOOPtQA2tdzHugEJMnODX+7my6dFwlworNdfZwrwuvz5",
"/build/app-rtl.88289026.js": "sha384-gOohic29TurHJC5g2lJbDPbhnbrpZv8dCzS2QrUOHToldu1kIhhV+nwIhrIcvh2M",
"/build/app-rtl.e8e3029e.css": "sha384-21kGyBRbajbE/gt4g8ej+clQSGAvYx1BMmiUMRTAE3ZVpMb5bvO/kVRcdKuV97J5",

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.49955ea2.css",
"build/app.js": "/build/app.77b6dec9.js",
"build/app.js": "/build/app.4f8430f7.js",
"build/app-rtl.css": "/build/app-rtl.e8e3029e.css",
"build/app-rtl.js": "/build/app-rtl.88289026.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -18,6 +18,7 @@ use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use App\User\TeamService;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -27,6 +28,7 @@ use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -308,4 +310,39 @@ final class ActivityController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Create team for activity
*
* If a team with the activity's name already exists, it is reused.
* The current user is added as teamlead (if not already), and the activity is bound to the team.
*/
#[IsGranted('create_team')]
#[IsGranted('permissions', 'activity')]
#[OA\Post(description: 'Creates (or reuses) a default team named after the activity, makes the current user a teamlead, and binds the activity to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', description: 'The activity to create a default team for', in: 'path', required: true)]
#[Route(path: '/{id}/team', name: 'post_activity_team', requirements: ['id' => '\d+'], methods: ['POST'])]
public function postDefaultTeamAction(Activity $activity, TeamService $teamService): Response
{
$name = $activity->getName();
if ($name === null || $name === '') {
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
}
$team = $teamService->findTeamByName($name);
if ($team === null) {
$team = $teamService->createNewTeam($name);
}
$team->addTeamlead($this->getUser());
$team->addActivity($activity);
$teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
}

View File

@@ -20,6 +20,7 @@ use App\Form\API\CustomerRateApiForm;
use App\Repository\CustomerRateRepository;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use App\User\TeamService;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -29,6 +30,7 @@ use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -401,4 +403,39 @@ final class CustomerController extends BaseApiController
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
}
/**
* Create team for customer
*
* If a team with the customer's name already exists, it is reused.
* The current user is added as teamlead (if not already), and the customer is bound to the team.
*/
#[IsGranted('create_team')]
#[IsGranted('permissions', 'customer')]
#[OA\Post(description: 'Creates (or reuses) a default team named after the customer, makes the current user a teamlead, and binds the customer to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', description: 'The customer to create a default team for', in: 'path', required: true)]
#[Route(path: '/{id}/team', name: 'post_customer_team', requirements: ['id' => '\d+'], methods: ['POST'])]
public function postDefaultTeamAction(Customer $customer, TeamService $teamService): Response
{
$name = $customer->getName();
if ($name === null || $name === '') {
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
}
$team = $teamService->findTeamByName($name);
if ($team === null) {
$team = $teamService->createNewTeam($name);
}
$team->addTeamlead($this->getUser());
$team->addCustomer($customer);
$teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
}

View File

@@ -21,6 +21,7 @@ use App\Repository\CustomerRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use App\User\TeamService;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -30,6 +31,7 @@ use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
@@ -457,4 +459,39 @@ final class ProjectController extends BaseApiController
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
}
/**
* Create team for project
*
* If a team with the project's name already exists, it is reused.
* The current user is added as teamlead (if not already), and the project is bound to the team.
*/
#[IsGranted('create_team')]
#[IsGranted('permissions', 'project')]
#[OA\Post(description: 'Creates (or reuses) a default team named after the project, makes the current user a teamlead, and binds the project to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', description: 'The project to create a default team for', in: 'path', required: true)]
#[Route(path: '/{id}/team', name: 'post_project_team', requirements: ['id' => '\d+'], methods: ['POST'])]
public function postDefaultTeamAction(Project $project, TeamService $teamService): Response
{
$name = $project->getName();
if ($name === null || $name === '') {
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
}
$team = $teamService->findTeamByName($name);
if ($team === null) {
$team = $teamService->createNewTeam($name);
}
$team->addTeamlead($this->getUser());
$team->addProject($project);
$teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
}

View File

@@ -170,6 +170,7 @@ final class TeamController extends BaseApiController
* Add team member
*/
#[IsGranted('edit', 'team')]
#[IsGranted('access_user', 'member')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
@@ -224,6 +225,7 @@ final class TeamController extends BaseApiController
* The team is granted access to the customer.
*/
#[IsGranted('edit', 'team')]
#[IsGranted('view', 'customer')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
@@ -274,6 +276,7 @@ final class TeamController extends BaseApiController
* The team is granted access to the project.
*/
#[IsGranted('edit', 'team')]
#[IsGranted('view', 'project')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
@@ -324,6 +327,7 @@ final class TeamController extends BaseApiController
* The team is granted access to the activity.
*/
#[IsGranted('edit', 'team')]
#[IsGranted('view', 'activity')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]

View File

@@ -429,16 +429,11 @@ final class TimesheetController extends BaseApiController
/**
* Stop active timesheet
*
* This route is available via GET and PATCH, as users over and over again run into errors when stopping.
* Likely caused by a slow JS engine and a fast-click after page reload.
*/
#[IsGranted('stop', 'timesheet')]
#[OA\Response(response: 200, description: 'Stops an active timesheet and returns it afterwards.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to stop', required: true)]
#[Route(methods: ['GET'], path: '/{id}/stop', name: 'stop_timesheet_get', requirements: ['id' => '\d+'])]
#[Route(methods: ['PATCH'], path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])]
#[OA\Get(x: ['internal' => true])]
public function stopAction(Timesheet $timesheet): Response
{
$this->service->stopTimesheet($timesheet);
@@ -457,8 +452,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted('start', 'timesheet')]
#[OA\Response(response: 200, description: 'Restart a timesheet for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to restart', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])]
#[Route(methods: ['PATCH'], path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
#[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')]
#[Rest\RequestParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Changes the restart date to the given one (default: now)')]

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.57.0';
public const VERSION = '2.58.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 25700;
public const VERSION_ID = 25800;
/**
* The software name
*/

View File

@@ -31,7 +31,6 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
@@ -40,7 +39,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -306,34 +304,6 @@ final class ActivityController extends AbstractController
]);
}
#[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'activity')]
public function createDefaultTeamAction(Activity $activity, TeamService $teamService): Response
{
$name = $activity->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) {
$defaultTeam = $teamService->createNewTeam($name);
}
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addActivity($activity);
try {
$teamService->saveTeam($defaultTeam);
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
}
#[Route(path: '/{id}/edit', name: 'admin_activity_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'activity')]
public function editAction(Activity $activity, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response

View File

@@ -34,7 +34,6 @@ use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -42,7 +41,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -188,34 +186,6 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'customer')]
public function createDefaultTeamAction(Customer $customer, TeamService $teamService): Response
{
$name = $customer->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) {
$defaultTeam = $teamService->createNewTeam($name);
}
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addCustomer($customer);
try {
$teamService->saveTeam($defaultTeam);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
#[Route(path: '/{id}/projects/{page}', defaults: ['page' => 1], name: 'customer_projects', methods: ['GET', 'POST'])]
#[IsGranted('view', 'customer')]
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository): Response

View File

@@ -32,6 +32,7 @@ final class DoctorController extends AbstractController
public const DIRECTORIES_WRITABLE = [
'var/cache/',
'var/log/',
'var/packages/',
];
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private FileHelper $fileHelper, private CacheInterface $cache)

View File

@@ -208,12 +208,14 @@ final class ExportController extends AbstractController
}
#[Route(path: '/template-create', name: 'export_template_create', methods: ['GET', 'POST'])]
#[IsGranted('create_export_template')]
public function createExportTemplate(Request $request, ExportTemplateRepository $repository): Response
{
return $this->editExportForm($this->generateUrl('export_template_create'), $request, $repository, new ExportTemplate());
}
#[Route(path: '/template-edit/{exportTemplate}', name: 'export_template_edit', methods: ['GET', 'POST'])]
#[IsGranted('create_export_template')]
public function editExportTemplate(ExportTemplate $exportTemplate, Request $request, ExportTemplateRepository $repository): Response
{
return $this->editExportForm($this->generateUrl('export_template_edit', ['exportTemplate' => $exportTemplate->getId()]), $request, $repository, $exportTemplate);

View File

@@ -37,7 +37,6 @@ use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\Context;
use App\Utils\DataTable;
use App\Utils\PageSetup;
@@ -46,7 +45,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -219,34 +217,6 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'project')]
public function createDefaultTeamAction(Project $project, TeamService $teamService): Response
{
$name = $project->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) {
$defaultTeam = $teamService->createNewTeam($name);
}
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addProject($project);
try {
$teamService->saveTeam($defaultTeam);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
#[Route(path: '/{id}/activities/{page}', defaults: ['page' => 1], name: 'project_activities', methods: ['GET', 'POST'])]
#[IsGranted('view', 'project')]
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository): Response

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,6 +57,16 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
return;
}
$preferredLanguage = null;
if (null !== ($token = $this->storage->getToken())) {
$user = $token->getUser();
if ($user instanceof User) {
$preferredLanguage = $user->getLanguage();
}
}
if ($preferredLanguage === null){
$allLanguages = $this->localeService->getTranslatedLocales();
// Add the default locale at the first position of the array, because getPreferredLanguage()
@@ -59,8 +74,9 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
array_unshift($allLanguages, 'en');
$preferredLanguage = $request->getPreferredLanguage(array_unique($allLanguages));
}
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage]));
$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;
}

View File

@@ -13,6 +13,8 @@ use App\Constants;
use App\Utils\FileHelper;
use Mpdf\Config\ConfigVariables;
use Mpdf\Config\FontVariables;
use Mpdf\Container\SimpleContainer;
use Mpdf\Http\ClientInterface;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
@@ -20,7 +22,8 @@ final class MPdfConverter implements HtmlToPdfConverter
{
public function __construct(
private readonly FileHelper $fileHelper,
private readonly string $cacheDirectory
private readonly string $cacheDirectory,
private readonly ?ClientInterface $httpClient = null,
)
{
}
@@ -116,7 +119,17 @@ final class MPdfConverter implements HtmlToPdfConverter
unset($options['additional_xmp_rdf']);
}
$mpdf = new Mpdf($options);
// Inject a safe HTTP client into mPDF (via its service container) so
// remote resources referenced from Twig templates — typically `<img
// src="...">` for company logos — cannot be abused to probe private
// networks. The configured Symfony client is decorated with
// NoPrivateNetworkHttpClient at the service-container level.
// @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
$container = $this->httpClient !== null
? new SimpleContainer(['httpClient' => $this->httpClient])
: null;
$mpdf = new Mpdf($options, $container);
$mpdf->creator = Constants::SOFTWARE;
if (\count($associatedFiles) > 0) {

View File

@@ -0,0 +1,76 @@
<?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\Pdf;
use Mpdf\Http\ClientInterface;
use Mpdf\PsrHttpMessageShim\Response;
use Psr\Http\Message\RequestInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Bridges mPDF's HTTP client to Symfonys NoPrivateNetworkHttpClient.
*
* Prevents the PDF renderer from issuing outbound requests to private network targets,
* closing the SSRF vector for `<img src="...">` references in custom Twig invoice templates.
*
* Blocked requests are translated to a non-2xx response so mPDF logs the failure
* and renders a placeholder for the missing image without aborting PDF generation.
*
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
*/
final class SafeRemoteContentClient implements ClientInterface
{
/**
* Timeout in seconds: a slow or unreachable remote target should not block PDF rendering.
*/
private const TIMEOUT = 10;
public function __construct(private readonly HttpClientInterface $client)
{
}
public function sendRequest(RequestInterface $request): Response
{
try {
$response = $this->client->request(
$request->getMethod(),
(string) $request->getUri(),
[
'headers' => $this->flattenHeaders($request),
'timeout' => self::TIMEOUT,
'max_duration' => self::TIMEOUT,
]
);
return new Response(
$response->getStatusCode(),
[],
$response->getContent(false)
);
} catch (HttpClientExceptionInterface) {
// Request blocked (private network), DNS failure, timeout, etc.
return new Response(502);
}
}
/**
* @return array<string, string>
*/
private function flattenHeaders(RequestInterface $request): array
{
$headers = [];
foreach ($request->getHeaders() as $name => $values) {
$headers[$name] = implode(', ', $values);
}
return $headers;
}
}

View File

@@ -36,6 +36,10 @@ final class PackageManager
*/
private function findAvailablePackages(string $path): array
{
if (!file_exists($path) || !is_readable($path) || !is_dir($path)) {
return [];
}
$packages = [];
$directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS);

View File

@@ -199,7 +199,7 @@ final class RolePermissionManager
return $this->checkTeamLeadAccess($timesheet->getUser()?->getTeams() ?? [], $user);
}
public function checkUserAccess(User $subject, User $user): bool
public function checkUserAccess(User $subject, User $user, bool $onlyEnabled = true): bool
{
if ($subject->getId() === $user->getId()) {
return true;
@@ -209,10 +209,12 @@ final class RolePermissionManager
return true;
}
if (!$subject->isEnabled()) {
if ($onlyEnabled && !$subject->isEnabled()) {
return false;
}
// system accounts are used for admins or API-only accounts
// and should not be accessed by less-privileged users (e.g. teamleads)
if (!$user->isSystemAccount() && $subject->isSystemAccount()) {
return false;
}

View File

@@ -17,7 +17,10 @@ final class MarkdownExtension implements RuntimeExtensionInterface
{
private ?bool $markdownEnabled = null;
public function __construct(private Markdown $markdown, private SystemConfiguration $configuration)
public function __construct(
private readonly Markdown $markdown,
private readonly SystemConfiguration $configuration
)
{
}
@@ -32,10 +35,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
/**
* Transforms entity and user comments (customer, project, activity ...) into HTML.
*
* @param string|null $content
* @param bool $fullLength
* @return string
*/
public function commentContent(?string $content, bool $fullLength = true): string
{
@@ -58,10 +57,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
/**
* Transforms the entities comment (customer, project, activity ...) into a one-liner.
*
* @param string|null $content
* @param bool $fullLength
* @return string
*/
public function commentOneLiner(?string $content, bool $fullLength = true): string
{
@@ -88,9 +83,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
/**
* Transforms the timesheet description content into HTML.
*
* @param string|null $content
* @return string
*/
public function timesheetContent(?string $content): string
{
@@ -107,9 +99,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
/**
* Transforms the given Markdown content into HTML
*
* @param string $content
* @return string
*/
public function markdownToHtml(string $content): string
{

View File

@@ -15,7 +15,7 @@ namespace App\Utils;
final class Markdown
{
private ?ParsedownExtension $parser = null;
private ?\Parsedown $parserFull = null;
private ?Parsedown $parserFull = null;
public function toHtml(string $text): string
{

View File

@@ -17,6 +17,20 @@ class Parsedown extends \Parsedown
/** @var array<string> */
private array $ids = [];
/**
* Overwritten to open links in new windows
*/
protected function inlineUrl($Excerpt): ?array // @phpstan-ignore missingType.parameter,missingType.iterableValue
{
$block = parent::inlineUrl($Excerpt);
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
$block['element']['attributes']['target'] = '_blank';
}
return $block;
}
protected function blockHeader($Line)
{
$block = parent::blockHeader($Line);
@@ -84,4 +98,42 @@ class Parsedown extends \Parsedown
return $Block;
}
/**
* Markdown image syntax `![alt](url)` is rewritten to a link `<a href="url">alt</a>`.
*
* Rationale: emitting `<img src="url">` would cause downstream renderers
* (e.g. mPDF on the server, browsers in the UI) to automatically fetch
* the remote URL. For server-side renderers this is a server-side request
* forgery vector; in the UI it is a tracking/privacy issue. Hand-written
* `<img>` in Twig templates (custom invoice templates etc.) is not
* affected — only images derived from Markdown input are neutralised
* here. The resulting `<a href>` is still passed through Parsedown's
* `safeLinksWhitelist` filtering when safe-mode is enabled.
*
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
*/
protected function inlineImage($Excerpt): ?array // @phpstan-ignore missingType.parameter,missingType.iterableValue
{
$Image = parent::inlineImage($Excerpt);
if ($Image === null) {
return null;
}
$src = $Image['element']['attributes']['src'] ?? '';
$alt = $Image['element']['attributes']['alt'] ?? '';
$Image['element'] = [
'name' => 'a',
'text' => $alt !== '' ? $alt : $src,
'attributes' => [
'href' => $src,
'rel' => 'noopener noreferrer',
'target' => '_blank',
],
];
return $Image;
}
}

View File

@@ -10,7 +10,7 @@
namespace App\Utils;
/**
* This Class extends the default Parsedown Class for custom methods.
* The default markdown implementation.
*/
final class ParsedownExtension extends Parsedown
{
@@ -42,31 +42,4 @@ final class ParsedownExtension extends Parsedown
'|' => ['Table'],
'~' => ['FencedCode'],
];
/**
* Overwritten to open links in new windows
*/
protected function inlineUrl($Excerpt): ?array
{
$block = parent::inlineUrl($Excerpt);
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
$block['element']['attributes']['target'] = '_blank';
}
return $block;
}
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$Block = parent::blockTable($Line, $Block);
if ($Block === null) {
return null;
}
$Block['element']['attributes']['class'] = 'table';
return $Block;
}
}

View File

@@ -83,7 +83,7 @@ final class TimesheetVoter extends Voter
{
$user = $token->getUser();
if (!($user instanceof User)) {
if (!($user instanceof User) || $user->getId() === null) {
return false;
}
@@ -91,10 +91,10 @@ final class TimesheetVoter extends Voter
switch ($attribute) {
case 'is_owner':
return (!$subject instanceof MultiUserTimesheet) && $user === $subject->getUser();
return (!$subject instanceof MultiUserTimesheet) && $user->getId() === $subject->getUser()?->getId();
case self::START:
if (!$this->canStart($subject)) {
if (!$this->canStart($user, $subject)) {
return false;
}
$permission .= $attribute;
@@ -115,7 +115,7 @@ final class TimesheetVoter extends Voter
break;
case 'duplicate':
if (!$this->canStart($subject)) {
if (!$this->canStart($user, $subject)) {
return false;
}
$permission = self::EDIT;
@@ -146,11 +146,10 @@ final class TimesheetVoter extends Voter
return $this->permissionManager->hasRolePermission($user, $permission . '_other_timesheet');
}
private function canStart(Timesheet $timesheet): bool
private function canStart(User $user, Timesheet $timesheet): bool
{
// possible improvements for the future:
// we could check the amount of active entries (maybe slow)
// if a teamlead starts an entry for another user, check that this user is part of his team (needs to be done for teams)
if (null === $timesheet->getActivity()) {
return false;
@@ -172,6 +171,18 @@ final class TimesheetVoter extends Voter
return false;
}
// starting and duplicating both create a NEW record under the referenced
// project and activity, so the current user must still have team-based
// access to them - historical ownership of the original timesheet is not
// sufficient (otherwise old entries would survive an access revocation).
if (!$this->permissionManager->checkTeamAccessProject($timesheet->getProject(), $user)) {
return false;
}
if (!$this->permissionManager->checkTeamAccessActivity($timesheet->getActivity(), $user)) {
return false;
}
return true;
}

View File

@@ -62,11 +62,7 @@ final class UserVoter extends Voter
{
$user = $token->getUser();
if (!($user instanceof User)) {
return false;
}
if (!($subject instanceof User)) {
if (!($user instanceof User) || !($subject instanceof User)) {
return false;
}
@@ -111,15 +107,14 @@ final class UserVoter extends Voter
$permission = $attribute;
// extend me for "team" support later on
if ($subject->getId() === $user->getId()) {
$permission .= '_own';
} else {
$permission .= '_other';
return $this->permissionManager->hasRolePermission($user, $permission . '_own_profile');
}
$permission .= '_profile';
if (!$this->permissionManager->hasRolePermission($user, $permission . '_other_profile')) {
return false;
}
return $this->permissionManager->hasRolePermission($user, $permission);
return $this->permissionManager->checkUserAccess($subject, $user, false);
}
}

View File

@@ -107,7 +107,7 @@
{% if teams is not null %}
{% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', activity) %}
{% set options = options|merge({'route_create': path('activity_team_create', {'id': activity.id}), 'route_edit': path('admin_activity_permissions', {'id': activity.id})}) %}
{% set options = options|merge({'route_create': path('post_activity_team', {'id': activity.id}), 'route_edit': path('admin_activity_permissions', {'id': activity.id})}) %}
{% endif %}
{% if activity.project is not null and (activity.project.teams|length > 0 or activity.project.customer.teams|length > 0) %}
{% set options = options|merge({'empty_message': 'team.activity_visibility_inherited'}) %}

View File

@@ -159,7 +159,7 @@
{% if teams is not null %}
{% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', customer) %}
{% set options = options|merge({'route_create': path('customer_team_create', {'id': customer.id}), 'route_edit': path('admin_customer_permissions', {'id': customer.id})}) %}
{% set options = options|merge({'route_create': path('post_customer_team', {'id': customer.id}), 'route_edit': path('admin_customer_permissions', {'id': customer.id})}) %}
{% endif %}
{{ include('embeds/teams.html.twig', options) }}
{% endif %}

View File

@@ -56,7 +56,7 @@
<td class="actions">
<div class="btn-group">
<a href="{{ path(edit_route, {'id': entity.id, 'rate': rate.id}) }}" class="modal-ajax-form btn btn-icon">{{ icon('edit', true) }}</a>
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error">{{ icon('delete', true) }}</a>
<a href="#" data-href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error">{{ icon('delete', true) }}</a>
</div>
</td>
</tr>

View File

@@ -11,7 +11,18 @@
{% import "macros/widgets.html.twig" as widgets %}
{% block box_tools %}
{% if route_create is not null and (teams|length == 0 or team is null) and is_granted('create_team') %}
{{ widgets.card_tool_button('create', {'title': 'team.create_default', 'translation_domain': 'teams', 'url': route_create}) }}
{{ widgets.card_tool_button('create', {
'title': 'team.create_default',
'translation_domain': 'teams',
'url': '#',
'class': 'api-link',
'attr': {
'data-href': route_create,
'data-method': 'POST',
'data-event': 'kimai.teamUpdate',
'data-msg-error': 'action.update.error'
}
}) }}
{% endif %}
{% if route_edit is not null %}
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': route_edit}) }}

View File

@@ -10,7 +10,7 @@
<div class="list-group-item">
<div class="row align-items-center">
<div class="col text-truncate" >
<a class="api-link text-decoration-none text-body d-block" href="{{ path('restart_timesheet', {'id': entry.id}) }}"
<a class="api-link text-decoration-none text-body d-block" href="#" data-href="{{ path('restart_timesheet', {'id': entry.id}) }}"
data-event="kimai.timesheetStart kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet.start.error"
data-msg-success="timesheet.start.success" data-bs-dismiss="modal">
{{ label_activity(entry.activity) }}

View File

@@ -154,7 +154,7 @@
{% if teams is not null%}
{% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', project) %}
{% set options = options|merge({'route_create': path('project_team_create', {'id': project.id}), 'route_edit': path('admin_project_permissions', {'id': project.id})}) %}
{% set options = options|merge({'route_create': path('post_project_team', {'id': project.id}), 'route_edit': path('admin_project_permissions', {'id': project.id})}) %}
{% endif %}
{% if project.customer.teams|length > 0 %}
{% set options = options|merge({'empty_message': 'team.project_visibility_inherited'}) %}

View File

@@ -3,7 +3,7 @@
{% block wizard_content %}
<div class="card-body text-center py-4 p-sm-7">
<img src="{{ asset('wizard/done.png') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
<img src="{{ asset('touch-icon-512x512.png') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
<h1 class="mt-5">{{ 'wizard.done.title'|trans({}, 'wizard') }}</h1>
<p class="text-body-secondary">{{ 'wizard.done.description'|trans({}, 'wizard') }}</p>
</div>

View File

@@ -2,7 +2,7 @@
{% block wizard_content %}
<div class="card-body text-center py-4 p-sm-7">
<img src="{{ asset('wizard/time-management.png') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
<img src="{{ asset('wizard/time-management.svg') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
<h1 class="mt-5">{{ 'wizard.intro.title'|trans({}, 'wizard') }}</h1>
<p class="text-body-secondary">{{ 'wizard.intro.description'|trans({}, 'wizard') }}</p>
</div>

View File

@@ -559,4 +559,49 @@ class ActivityControllerTest extends APIControllerBaseTestCase
'message' => 'Not Found'
]);
}
public function testPostDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/activities/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertIsNumeric($result['id']);
$teamId = $result['id'];
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
self::assertIsArray($result['members'][0]);
self::assertArrayHasKey('teamlead', $result['members'][0]);
self::assertTrue($result['members'][0]['teamlead']);
// idempotent
$this->request($client, '/api/activities/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($teamId, $result['id']);
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
}
public function testPostDefaultTeamActionIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/activities/1/team', 'POST');
}
public function testPostDefaultTeamActionNotFound(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/activities/' . PHP_INT_MAX . '/team');
}
}

View File

@@ -66,6 +66,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/activities/{id}/meta',
'/api/activities/{id}/rates',
'/api/activities/{id}/rates/{rateId}',
'/api/activities/{id}/team',
'/api/config/timesheet',
'/api/config/colors',
'/api/customers',
@@ -76,6 +77,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/customers/{id}/comments',
'/api/customers/{id}/comments/{comment}/pin',
'/api/customers/{id}/comments/{comment}',
'/api/customers/{id}/team',
'/api/export/{id}',
'/api/invoices',
'/api/invoices/{id}',
@@ -89,6 +91,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/projects/{id}/comments',
'/api/projects/{id}/comments/{comment}/pin',
'/api/projects/{id}/comments/{comment}',
'/api/projects/{id}/team',
'/api/ping',
'/api/version',
'/api/plugins',

View File

@@ -874,4 +874,50 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertNull($this->getEntityManager()->getRepository(CustomerComment::class)->find($commentId));
}
public function testPostDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/customers/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertIsNumeric($result['id']);
$teamId = $result['id'];
// verify customer is bound and current user is teamlead
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
self::assertIsArray($result['members'][0]);
self::assertArrayHasKey('teamlead', $result['members'][0]);
self::assertTrue($result['members'][0]['teamlead']);
// idempotent: calling again returns the same team without duplicate bindings or members
$this->request($client, '/api/customers/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($teamId, $result['id']);
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
}
public function testPostDefaultTeamActionIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/customers/1/team', 'POST');
}
public function testPostDefaultTeamActionNotFound(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/customers/' . PHP_INT_MAX . '/team');
}
}

View File

@@ -983,4 +983,49 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertNull($this->getEntityManager()->getRepository(ProjectComment::class)->find($commentId));
}
public function testPostDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/projects/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertIsNumeric($result['id']);
$teamId = $result['id'];
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
self::assertIsArray($result['members'][0]);
self::assertArrayHasKey('teamlead', $result['members'][0]);
self::assertTrue($result['members'][0]['teamlead']);
// idempotent
$this->request($client, '/api/projects/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($teamId, $result['id']);
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
}
public function testPostDefaultTeamActionIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/projects/1/team', 'POST');
}
public function testPostDefaultTeamActionNotFound(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/projects/' . PHP_INT_MAX . '/team');
}
}

View File

@@ -9,9 +9,16 @@
namespace App\Tests\API;
use App\DataFixtures\UserFixtures;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Role;
use App\Entity\RolePermission;
use App\Entity\Team;
use App\Entity\User;
use App\Tests\DataFixtures\TeamFixtures;
use App\User\PermissionService;
use Doctrine\ORM\EntityManager;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
@@ -778,4 +785,334 @@ class TeamControllerTest extends APIControllerBaseTestCase
// cannot remove activity
$this->assertBadRequest($client, '/api/teams/' . $result['id'] . '/activities/1', 'DELETE');
}
/**
* Sets up tony_teamlead so that he has the `edit_team` permission via a
* dedicated test role, and makes him the teamlead of a fresh team.
*
* This simulates an installation that lets teamleads manage their own
* teams. The permission is routed through PermissionService so the shared
* cache is invalidated and the request kernel sees the new permission.
*
* @return Team the team the attacker is teamlead of
*/
private function prepareAttackerTeamleadWithEditTeam(string $suffix): Team
{
$em = $this->getEntityManager();
$roleName = 'TEST_EDIT_TEAM_' . $suffix;
$role = (new Role())->setName($roleName);
$permission = (new RolePermission())->setRole($role)->setPermission('edit_team')->setAllowed(true);
$em->persist($role);
$p = self::getContainer()->get(PermissionService::class);
self::assertInstanceOf(PermissionService::class, $p);
$p->saveRolePermission($permission);
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
$attacker->addRole($roleName);
$em->persist($attacker);
$attackerTeam = new Team('GHSA-xv4r attacker team ' . $suffix);
$attackerTeam->addTeamlead($attacker);
$em->persist($attackerTeam);
$em->flush();
return $attackerTeam;
}
/**
* Regression test for GHSA-xv4r-4885-gwpg.
*
* A teamlead with edit_team permission must not be able to add a user
* that falls outside their authorized management scope by calling the
* member-assignment API directly. The frontend hides those users; the
* backend has to enforce the same boundary.
*/
public function testPostMemberActionDeniesUserOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_MEMBER');
// target user is in a separate team that the attacker has no role in,
// and the target is not a "regular-user-only without any teams" (which
// would otherwise be visible to any teamlead).
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
$isolatedTeam = new Team('GHSA-xv4r isolated team');
$isolatedTeam->addUser($target);
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
$em->persist($isolatedTeam);
$em->flush();
$teamId = $attackerTeam->getId();
$targetId = $target->getId();
self::assertIsInt($teamId);
self::assertIsInt($targetId);
$this->request($client, '/api/teams/' . $teamId . '/members/' . $targetId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
// verify the relation was NOT persisted
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
self::assertFalse($reloaded->hasUser($target));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg.
*
* The teamlead must not be able to attach an activity that they cannot
* view in the first place, even when they may edit the team.
*/
public function testPostActivityActionDeniesActivityOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_ACTIVITY');
// activity is created without any team relation that the attacker is part of
$customer = new Customer('GHSA-xv4r activity customer');
$customer->setCountry('DE');
$customer->setTimezone('Europe/Berlin');
$em->persist($customer);
$project = new Project();
$project->setName('GHSA-xv4r activity project');
$project->setCustomer($customer);
$em->persist($project);
$activity = new Activity();
$activity->setName('GHSA-xv4r out-of-scope activity');
$activity->setProject($project);
$em->persist($activity);
$em->flush();
$teamId = $attackerTeam->getId();
$activityId = $activity->getId();
self::assertIsInt($teamId);
self::assertIsInt($activityId);
$this->request($client, '/api/teams/' . $teamId . '/activities/' . $activityId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
$reloadedActivity = $em->getRepository(Activity::class)->find($activityId);
self::assertInstanceOf(Activity::class, $reloadedActivity);
self::assertFalse($reloaded->hasActivity($reloadedActivity));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (postCustomerAction variant).
*
* A teamlead with edit_team permission must not be able to grant their
* team access to a customer that they cannot view themselves. The bug
* pattern is identical to the postActivityAction variant.
*/
public function testPostCustomerActionDeniesCustomerOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_CUSTOMER');
// customer has no team relation to the attacker -> attacker has no view permission on it
$customer = new Customer('GHSA-xv4r out-of-scope customer');
$customer->setCountry('DE');
$customer->setTimezone('Europe/Berlin');
$em->persist($customer);
$em->flush();
$teamId = $attackerTeam->getId();
$customerId = $customer->getId();
self::assertIsInt($teamId);
self::assertIsInt($customerId);
$this->request($client, '/api/teams/' . $teamId . '/customers/' . $customerId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
$reloadedCustomer = $em->getRepository(Customer::class)->find($customerId);
self::assertInstanceOf(Customer::class, $reloadedCustomer);
self::assertFalse($reloaded->hasCustomer($reloadedCustomer));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (postProjectAction variant).
*
* A teamlead with edit_team permission must not be able to grant their
* team access to a project that they cannot view themselves.
*/
public function testPostProjectActionDeniesProjectOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_PROJECT');
$customer = new Customer('GHSA-xv4r project customer');
$customer->setCountry('DE');
$customer->setTimezone('Europe/Berlin');
$em->persist($customer);
$project = new Project();
$project->setName('GHSA-xv4r out-of-scope project');
$project->setCustomer($customer);
$em->persist($project);
$em->flush();
$teamId = $attackerTeam->getId();
$projectId = $project->getId();
self::assertIsInt($teamId);
self::assertIsInt($projectId);
$this->request($client, '/api/teams/' . $teamId . '/projects/' . $projectId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
$reloadedProject = $em->getRepository(Project::class)->find($projectId);
self::assertInstanceOf(Project::class, $reloadedProject);
self::assertFalse($reloaded->hasProject($reloadedProject));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (patchAction variant).
*
* The PATCH /api/teams/{id} endpoint takes a `members` array and replaces
* the team's membership. A teamlead with edit_team permission must not be
* able to attach an out-of-scope user this way.
*/
public function testPatchActionDeniesAddingOutOfScopeMember(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_PATCH');
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
$attackerId = $attacker->getId();
self::assertIsInt($attackerId);
// target user kept out of attacker's reach
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
$isolatedTeam = new Team('GHSA-xv4r isolated team patch');
$isolatedTeam->addUser($target);
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
$em->persist($isolatedTeam);
$em->flush();
$teamId = $attackerTeam->getId();
$targetId = $target->getId();
self::assertIsInt($teamId);
self::assertIsInt($targetId);
$payload = [
'name' => 'GHSA-xv4r patch team',
'members' => [
['user' => $attackerId, 'teamlead' => true],
['user' => $targetId, 'teamlead' => false],
],
];
$this->request($client, '/api/teams/' . $teamId, 'PATCH', [], json_encode($payload));
$response = $client->getResponse();
// either a hard 403 or a validation rejection of the members field is acceptable;
// any 2xx that ends with the target attached to the team is the security failure.
self::assertFalse(
$response->isSuccessful() && str_contains((string) $response->getContent(), '"id"'),
'PATCH /api/teams must not silently attach an out-of-scope user via the members array.'
);
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
self::assertFalse(
$reloaded->hasUser($target),
'Out-of-scope user must not have been added to the team via PATCH.'
);
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (postAction variant).
*
* The POST /api/teams endpoint accepts a `members` array. A user whose
* role grants `create_team` but not `view_all_data` must not be able to
* create a team with members they cannot manage. This covers the
* non-admin "team creator" role configuration.
*/
public function testPostActionDeniesCreatingTeamWithOutOfScopeMember(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
// grant create_team to a custom role and attach it to tony_teamlead
$roleName = 'TEST_CREATE_TEAM_GHSA_XV4R';
$role = (new Role())->setName($roleName);
$permission = (new RolePermission())->setRole($role)->setPermission('create_team')->setAllowed(true);
$em->persist($role);
$p = self::getContainer()->get(PermissionService::class);
self::assertInstanceOf(PermissionService::class, $p);
$p->saveRolePermission($permission);
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
$attacker->addRole($roleName);
$em->persist($attacker);
$attackerId = $attacker->getId();
self::assertIsInt($attackerId);
// target user is unreachable for the attacker
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
$isolatedTeam = new Team('GHSA-xv4r isolated team create');
$isolatedTeam->addUser($target);
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
$em->persist($isolatedTeam);
$em->flush();
$targetId = $target->getId();
self::assertIsInt($targetId);
$payload = [
'name' => 'GHSA-xv4r created team',
'members' => [
['user' => $attackerId, 'teamlead' => true],
['user' => $targetId, 'teamlead' => false],
],
];
$this->request($client, '/api/teams', 'POST', [], json_encode($payload));
$response = $client->getResponse();
$body = (string) $response->getContent();
// success body would contain the new id and the target as a member -> security failure
if ($response->isSuccessful()) {
$decoded = json_decode($body, true);
self::assertIsArray($decoded);
$memberIds = [];
if (\is_array($decoded['members'] ?? null)) {
foreach ($decoded['members'] as $entry) {
if (\is_array($entry) && \is_array($entry['user'] ?? null) && isset($entry['user']['id'])) {
$memberIds[] = $entry['user']['id'];
}
}
}
self::assertNotContains(
$targetId,
$memberIds,
'POST /api/teams must not silently accept an out-of-scope user in the members array.'
);
}
}
}

View File

@@ -1528,6 +1528,72 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/11/duplicate', []);
}
// ------------------------------------------------------------------
// GHSA-c6w6-57jj-62vh — restart/duplicate after project access revocation.
//
// "restart" and "duplicate" derive a NEW timesheet from a historical
// entry the user still owns. Once the user's team access to the underlying
// project/activity is revoked, neither operation may create a new record
// under it. The data write itself is already blocked by
// TimesheetTeamAccessValidator (since 2.57); these tests additionally pin
// that the TimesheetVoter denies the request at the authorization layer —
// a clean 403, not an incidental 400 from downstream validation.
// ------------------------------------------------------------------
public function testRestartAndDuplicateDeniedAfterProjectAccessRevoked(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
// The customer is restricted to a team the user is NOT a member of:
// the user's access to this project/activity has been revoked, but
// their historical timesheet still references it.
$revokedTeam = new Team('GHSA-c6w6 team without access');
$em->persist($revokedTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$revokedTeam], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$before = $this->getEntityManager()->getRepository(Timesheet::class)->count([]);
// PATCH + GET .../restart
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
// PATCH .../duplicate
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
// No new record may have been persisted under the revoked project.
$after = $this->getEntityManager()->getRepository(Timesheet::class)->count([]);
self::assertSame($before, $after, 'restart/duplicate leaked through and created a new timesheet under the revoked project');
}
public function testRestartAndDuplicateAllowedWhenUserStillHasProjectAccess(): void
{
// Positive control: as long as the user still has team access to the
// project/activity, restart and duplicate keep working.
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$team = new Team('GHSA-c6w6 team with access');
$team->addUser($owner);
$em->persist($team);
$timesheet = $this->persistRestrictedTimesheet($owner, [$team], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
self::assertTrue($client->getResponse()->isSuccessful(), 'restart must succeed while the user still has project access');
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
self::assertTrue($client->getResponse()->isSuccessful(), 'duplicate must succeed while the user still has project access');
}
public function testExportAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -1695,17 +1761,17 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
$this->assertApiResponseAccessDenied($client->getResponse());
// 3) PATCH /api/timesheets/{id}/stop and 4) GET .../stop
// 3) PATCH /api/timesheets/{id}/stop is access-denied; 4) GET .../stop is no longer routable
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
// 5) PATCH /api/timesheets/{id}/restart and 6) GET .../restart
// 5) PATCH /api/timesheets/{id}/restart is access-denied; 6) GET .../restart is no longer routable
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/restart', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
// 7) PATCH /api/timesheets/{id}/duplicate
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
@@ -1931,7 +1997,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
// Confirm side-effect-free: timesheet must still be running.
$em->clear();

View File

@@ -350,22 +350,6 @@ class ActivityControllerTest extends AbstractControllerBaseTestCase
self::assertEquals(2, $activity->getTeams()->count());
}
public function testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/activity/1/details');
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text());
$this->request($client, '/admin/activity/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text());
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testDeleteAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -216,22 +216,6 @@ class CustomerControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
public function testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text(null, true));
$this->request($client, '/admin/customer/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testProjectsAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -265,6 +265,24 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/template-create');
}
public function testCreateTemplateIsSecureForTeamlead(): void
{
// GHSA-rw46-qg69-vg6h
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/export/template-create');
}
public function testEditTemplateIsSecureForTeamlead(): void
{
// GHSA-rw46-qg69-vg6h
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
/** @var ExportTemplate[] $templates */
$templates = $this->importFixture(new ExportTemplateFixtures());
$id = $templates[0]->getId();
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
$this->assertAccessDenied($client);
}
public function testCreateTemplateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -40,7 +40,8 @@ class FavoriteControllerTest extends AbstractControllerBaseTestCase
$content = $client->getResponse()->getContent();
self::assertNotFalse($content);
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="/api/timesheets/', $content);
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="#', $content);
self::assertStringContainsString('data-href="/api/timesheets/', $content);
self::assertStringContainsString('data-event="kimai.timesheetStart kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet', $content);
}

View File

@@ -331,22 +331,6 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
public function testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text(null, true));
$this->request($client, '/admin/project/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testActivitiesAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -0,0 +1,216 @@
<?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\Tests\EventSubscriber;
use App\Entity\User;
use App\EventSubscriber\PasswordResetSubscriber;
use App\EventSubscriber\WizardSubscriber;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
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\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[CoversClass(PasswordResetSubscriber::class)]
class PasswordResetSubscriberTest extends TestCase
{
public function testGetSubscribedEvents(): void
{
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', -20]], PasswordResetSubscriber::getSubscribedEvents());
}
public function testPasswordResetHasHigherPriorityThanWizardSubscriber(): void
{
self::assertGreaterThan(
WizardSubscriber::getSubscribedEvents()[KernelEvents::REQUEST][1],
PasswordResetSubscriber::getSubscribedEvents()[KernelEvents::REQUEST][1]
);
}
public function testOnKernelRequestIgnoresSubRequest(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard', false);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresMissingToken(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
/**
* @return iterable<array{string}>
*/
public static function provideExcludedUris(): iterable
{
yield ['/api/timesheets'];
yield ['/register/new'];
yield ['/wizard/intro'];
}
#[DataProvider('provideExcludedUris')]
public function testOnKernelRequestIgnoresExcludedUris(string $uri): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->never())->method('getUser');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent($uri);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresNonUserToken(): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($this->createMock(UserInterface::class));
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresUserWithoutFullAuthentication(): void
{
$user = new User();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(false);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresUserWithoutPasswordReset(): void
{
$user = new User();
$user->setEnabled(true);
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestRedirectsToPasswordWizard(): void
{
$user = new User();
$user->setEnabled(true);
$user->setRequiresPasswordReset();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('wizard', ['wizard' => 'password'])
->willReturn('/wizard/password');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/wizard/password', $response->headers->get('Location'));
}
private function createUserToken(User $user): TokenInterface
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($user);
return $token;
}
private function createRequestEvent(string $uri, bool $mainRequest = true): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($uri);
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
}

View File

@@ -10,31 +10,152 @@
namespace App\Tests\EventSubscriber;
use App\Configuration\LocaleService;
use App\Entity\User;
use App\EventSubscriber\RedirectToLocaleSubscriber;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
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\Authentication\Token\TokenInterface;
#[CoversClass(RedirectToLocaleSubscriber::class)]
class RedirectToLocaleSubscriberTest extends TestCase
{
public function testConstruct(): void
public function testGetSubscribedEvents(): void
{
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', 0]], RedirectToLocaleSubscriber::getSubscribedEvents());
}
public function testOnKernelRequestIgnoresNonHomepageRequest(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$sut = new RedirectToLocaleSubscriber($urlGenerator, new LocaleService(['de' => LocaleService::DEFAULT_SETTINGS, 'en' => LocaleService::DEFAULT_SETTINGS]));
$urlGenerator->expects($this->never())->method('generate');
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest']], RedirectToLocaleSubscriber::getSubscribedEvents());
$request = $this->createMock(Request::class);
$request->expects($this->once())->method('getPathInfo')->willReturn('/de');
$event = $this->createMock(RequestEvent::class);
$event->expects($this->once())->method('getRequest')->willReturn($request);
$event->expects($this->never())->method('setResponse');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/de');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresHomepageWithSameHostReferer(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/', ['referer' => 'https://www.kimai.test/de/dashboard']);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestRedirectsAuthenticatedUserToLanguage(): void
{
$user = new User();
$user->setLanguage('fr');
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($user);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('homepage', ['_locale' => 'fr'])
->willReturn('/fr');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/');
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/fr', $response->headers->get('Location'));
}
public function testOnKernelRequestRedirectsAnonymousUserToPreferredBrowserLanguage(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('homepage', ['_locale' => 'de'])
->willReturn('/de');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/', ['Accept-Language' => 'de-DE,de;q=0.9,en;q=0.8']);
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/de', $response->headers->get('Location'));
}
public function testOnKernelRequestFallsBackToDefaultLocaleForAnonymousUser(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('homepage', ['_locale' => 'en'])
->willReturn('/en');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/', ['Accept-Language' => 'es-ES,es;q=0.9']);
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/en', $response->headers->get('Location'));
}
private function createLocaleService(): LocaleService
{
return new LocaleService([
'de' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
'en' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
'fr' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
]);
}
/**
* @param array<string, string> $headers
*/
private function createRequestEvent(string $uri, array $headers = []): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($uri, 'GET', [], [], [], ['HTTP_HOST' => 'www.kimai.test', 'HTTPS' => 'on']);
foreach ($headers as $name => $value) {
$request->headers->set($name, $value);
}
return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
}
}

View File

@@ -0,0 +1,145 @@
<?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\Tests\EventSubscriber;
use App\Configuration\LocaleService;
use App\Entity\User;
use App\EventSubscriber\UserEnvironmentSubscriber;
use App\Twig\LocaleFormatExtensions;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
#[CoversClass(UserEnvironmentSubscriber::class)]
class UserEnvironmentSubscriberTest extends TestCase
{
private string $defaultLocale;
private string $defaultTimezone;
protected function setUp(): void
{
$this->defaultLocale = \Locale::getDefault();
$this->defaultTimezone = date_default_timezone_get();
}
protected function tearDown(): void
{
\Locale::setDefault($this->defaultLocale);
date_default_timezone_set($this->defaultTimezone);
}
public function testGetSubscribedEvents(): void
{
self::assertEquals([
KernelEvents::REQUEST => ['prepareEnvironment', -10],
KernelEvents::FINISH_REQUEST => ['restoreLocale', -20],
], UserEnvironmentSubscriber::getSubscribedEvents());
}
public function testPrepareEnvironmentUsesRequestLocaleWithoutAuthenticatedUser(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->expects($this->never())->method('isGranted');
$localeExtension = $this->createLocaleFormatExtensions();
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
$sut->prepareEnvironment($this->createRequestEvent('fr', true));
self::assertSame('fr', \Locale::getDefault());
self::assertSame('fr', $localeExtension->getLocale());
self::assertSame($this->defaultTimezone, date_default_timezone_get());
}
public function testPrepareEnvironmentUsesUserLocaleTimezoneAndPermission(): void
{
$user = new User();
$user->setLocale('de');
$user->setTimezone('Europe/Berlin');
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($user);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->expects($this->once())->method('isGranted')->with('view_all_data')->willReturn(true);
$localeExtension = $this->createLocaleFormatExtensions();
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
$sut->prepareEnvironment($this->createRequestEvent('en', true));
self::assertSame('de', \Locale::getDefault());
self::assertSame('de', $localeExtension->getLocale());
self::assertSame('Europe/Berlin', date_default_timezone_get());
self::assertTrue($user->canSeeAllData());
}
public function testRestoreLocaleAfterSubRequest(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->expects($this->never())->method('isGranted');
$localeExtension = $this->createLocaleFormatExtensions();
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
$sut->prepareEnvironment($this->createRequestEvent('de', true));
\Locale::setDefault('it');
$localeExtension->setLocale('it');
$sut->restoreLocale($this->createFinishRequestEvent(false));
self::assertSame('de', \Locale::getDefault());
self::assertSame('de', $localeExtension->getLocale());
}
private function createLocaleFormatExtensions(): LocaleFormatExtensions
{
return new LocaleFormatExtensions(new LocaleService([
'de' => LocaleService::DEFAULT_SETTINGS,
'en' => LocaleService::DEFAULT_SETTINGS,
'fr' => LocaleService::DEFAULT_SETTINGS,
'it' => LocaleService::DEFAULT_SETTINGS,
]));
}
private function createRequestEvent(string $locale, bool $mainRequest): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = new Request();
$request->setLocale($locale);
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
private function createFinishRequestEvent(bool $mainRequest): FinishRequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = new Request();
return new FinishRequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
}

View File

@@ -9,20 +9,214 @@
namespace App\Tests\EventSubscriber;
use App\Entity\User;
use App\EventSubscriber\WizardSubscriber;
use App\Tests\Mocks\SystemConfigurationFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
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\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[CoversClass(WizardSubscriber::class)]
class WizardSubscriberTest extends TestCase
{
public function testGetSubscribedEvents(): void
{
$events = WizardSubscriber::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
$methodName = $events[KernelEvents::REQUEST][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(WizardSubscriber::class, $methodName));
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', -30]], WizardSubscriber::getSubscribedEvents());
}
public function testOnKernelRequestIgnoresSubRequest(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$event = $this->createRequestEvent('/dashboard', false);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresMissingToken(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
/**
* @return iterable<array{string}>
*/
public static function provideExcludedUris(): iterable
{
yield ['/api/timesheets'];
yield ['/register/new'];
yield ['/wizard/intro'];
}
#[DataProvider('provideExcludedUris')]
public function testOnKernelRequestIgnoresExcludedUris(string $uri): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->never())->method('getUser');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$event = $this->createRequestEvent($uri);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresNonUserToken(): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($this->createMock(UserInterface::class));
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresUserWithoutFullAuthentication(): void
{
$user = new User();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(false);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresWizardForRegularUserIfDisabled(): void
{
$user = new User();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => false,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestRedirectsToFirstUnseenWizard(): void
{
$user = new User();
$user->setWizardAsSeen('intro');
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('wizard', ['wizard' => 'profile'])
->willReturn('/wizard/profile');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/wizard/profile', $response->headers->get('Location'));
}
private function createUserToken(User $user): TokenInterface
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($user);
return $token;
}
private function createRequestEvent(string $uri, bool $mainRequest = true): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($uri);
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
}

View File

@@ -0,0 +1,77 @@
<?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\Tests\Pdf;
use App\Pdf\SafeRemoteContentClient;
use Mpdf\PsrHttpMessageShim\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
#[CoversClass(SafeRemoteContentClient::class)]
class SafeRemoteContentClientTest extends TestCase
{
public function testSuccessfulResponseIsForwarded(): void
{
$client = new MockHttpClient(
new MockResponse('image-bytes', ['http_code' => 200])
);
$sut = new SafeRemoteContentClient($client);
$response = $sut->sendRequest(new Request('GET', 'https://example.com/logo.png'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('image-bytes', $response->getBody()->getContents());
}
public function testNon2xxResponseIsForwardedWithoutThrowing(): void
{
$client = new MockHttpClient(
new MockResponse('not found', ['http_code' => 404])
);
$sut = new SafeRemoteContentClient($client);
$response = $sut->sendRequest(new Request('GET', 'https://example.com/missing.png'));
self::assertSame(404, $response->getStatusCode());
}
public function testTransportExceptionResultsInNon2xxResponse(): void
{
// Simulates NoPrivateNetworkHttpClient blocking the request, a DNS
// failure, or a connection timeout — none of which must crash the
// PDF rendering pipeline.
$client = new MockHttpClient(static function (): MockResponse {
throw new TransportException('IP blocked');
});
$sut = new SafeRemoteContentClient($client);
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
self::assertSame(502, $response->getStatusCode());
}
public function testRequestIsBlockedWhenWrappedWithNoPrivateNetworkHttpClient(): void
{
// Wraps a mock client that would otherwise succeed. The decorator
// must reject the localhost URL before any request is dispatched.
$inner = new MockHttpClient(new MockResponse('should-not-be-reached'));
$safe = new NoPrivateNetworkHttpClient($inner);
$sut = new SafeRemoteContentClient($safe);
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
self::assertSame(502, $response->getStatusCode());
self::assertSame('', $response->getBody()->getContents());
}
}

View File

@@ -0,0 +1,81 @@
<?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\Tests\Security;
use App\Entity\User;
use App\Tests\KernelTestTrait;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkException;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
/**
* Regression test for GHSA-m492-gv72-xvxj: a login link (used for password
* reset and admin on-demand login) must stop working once the user's password
* has been changed.
*/
#[Group('integration')]
class LoginLinkTest extends KernelTestCase
{
use KernelTestTrait;
private Request $request;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
// the login link handler is firewall-aware and needs an active request
// on the stack to resolve the firewall it belongs to
$this->request = Request::create('http://localhost/');
$stack = self::getContainer()->get(RequestStack::class);
self::assertInstanceOf(RequestStack::class, $stack);
$stack->push($this->request);
}
private function getLoginLinkHandler(): LoginLinkHandlerInterface
{
/** @var LoginLinkHandlerInterface $handler */
$handler = self::getContainer()->get(LoginLinkHandlerInterface::class);
return $handler;
}
public function testLoginLinkIsValidBeforePasswordChange(): void
{
$user = $this->getUserByRole(User::ROLE_USER);
$handler = $this->getLoginLinkHandler();
$link = $handler->createLoginLink($user, $this->request);
$consumed = $handler->consumeLoginLink(Request::create($link->getUrl()));
self::assertSame($user->getUserIdentifier(), $consumed->getUserIdentifier());
}
public function testLoginLinkIsRejectedAfterPasswordChange(): void
{
$user = $this->getUserByRole(User::ROLE_USER);
$handler = $this->getLoginLinkHandler();
$link = $handler->createLoginLink($user, $this->request);
// simulate the user completing the password reset wizard: the password
// hash changes, which must invalidate the signature of the old link
$user->setPassword('$2y$13$changedchangedchangedchangedchangedchangedchangedchangedchg');
$this->getEntityManager()->flush();
$this->expectException(InvalidLoginLinkException::class);
$handler->consumeLoginLink(Request::create($link->getUrl()));
}
}

View File

@@ -859,6 +859,90 @@ class RolePermissionManagerTest extends TestCase
self::assertFalse($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessAllowsDisabledSubjectWhenOnlyEnabledIsFalse(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$requester = self::userWithId(2);
$team = new Team('Support');
$team->addUser($subject);
$team->addTeamlead($requester);
// with $onlyEnabled = true (default) the disabled flag denies access
self::assertFalse($sut->checkUserAccess($subject, $requester));
// with $onlyEnabled = false the disabled flag is ignored and the teamlead path grants access
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseStillRequiresAccessPath(): void
{
// disabling the "enabled" check must not bypass the rest of the access logic:
// a requester without team relation must still be denied.
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
(new Team('Subject team'))->addUser($subject);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_TEAMLEAD]);
self::assertFalse($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseStillBlocksSystemAccountSubject(): void
{
// the system-account guard sits below the "enabled" check, so it must still
// apply when $onlyEnabled = false.
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$subject->setSystemAccount(true);
$requester = self::userWithId(2);
$team = new Team('Support');
$team->addUser($subject);
$team->addTeamlead($requester);
self::assertFalse($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseGrantsAdminFallbackForDisabledTeamlessUser(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_ADMIN]);
self::assertTrue($subject->isRegularUserOnly());
self::assertSame([], $subject->getTeams());
self::assertFalse($sut->checkUserAccess($subject, $requester));
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseStillGrantsSuperAdmin(): void
{
// super-admin / canSeeAllData is decided before the "enabled" check,
// so the result must not change with the flag.
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$requester = self::userWithId(2);
$requester->setSuperAdmin(true);
self::assertTrue($sut->checkUserAccess($subject, $requester));
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessDeniesSystemAccountForNonSystemRequester(): void
{
$sut = $this->createSut();

View File

@@ -28,7 +28,7 @@ class ParsedownExtensionTest extends TestCase
| Another entry | € 111 |
| | |
| Total | A lot |');
self::assertStringStartsWith('<table class="table">', $html);
self::assertStringStartsWith('<table class="table table-striped table-vcenter">', $html);
}
public function testHeaderIsNotConverted(): void
@@ -39,4 +39,23 @@ class ParsedownExtensionTest extends TestCase
');
self::assertEquals('<p># Foo</p>', $html);
}
/**
* Markdown image syntax must never emit an `<img>` tag, otherwise mPDF
* (server-side) or the browser (UI) would auto-fetch the remote URL.
*
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
*/
public function testMarkdownImageIsRewrittenAsLink(): void
{
$sut = new ParsedownExtension();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![probe](http://attacker.example/p.png)');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
self::assertStringContainsString('>probe</a>', $html);
}
}

View File

@@ -50,4 +50,74 @@ class ParsedownTest extends TestCase
<h1 id="foo-1">Foo</h1>
<h1 id="foo-2">Foo</h1>', $html);
}
/**
* Markdown image syntax must never emit an `<img>` tag, otherwise mPDF
* (server-side) or the browser (UI) would auto-fetch the remote URL.
*
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
*/
public function testMarkdownImageIsRewrittenAsLink(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![probe](http://attacker.example/p.png)');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
self::assertStringContainsString('>probe</a>', $html);
self::assertStringContainsString('target="_blank"', $html);
self::assertStringContainsString('rel="noopener noreferrer"', $html);
}
public function testMarkdownImageWithoutAltUsesUrlAsLabel(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![](http://attacker.example/p.png)');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('>http://attacker.example/p.png</a>', $html);
}
public function testReferenceStyleImageIsRewrittenAsLink(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text("![probe][ref]\n\n[ref]: http://attacker.example/p.png");
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
self::assertStringContainsString('>probe</a>', $html);
}
public function testRawHtmlImageIsEscaped(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('<img src="http://attacker.example/p.png">');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('&lt;img', $html);
}
public function testJavascriptUrlInImageIsNeutralised(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![x](javascript:alert(1))');
self::assertStringNotContainsString('<img', $html);
self::assertStringNotContainsString('href="javascript:', $html);
}
}

View File

@@ -192,19 +192,6 @@ class TimesheetVoterTest extends AbstractVoterTestCase
$this->assertVote($other, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
}
public function testIsOwnerUsesObjectIdentityNotId(): void
{
// The is_owner branch compares with strict identity ($user === $subject->getUser()),
// not by id like the permission-based branches. Two distinct User instances that
// share the same id are therefore NOT considered the same owner.
$tokenUser = self::getUser(1, User::ROLE_USER);
$timesheetUser = self::getUser(1, User::ROLE_USER);
$timesheet = self::getTimesheet($timesheetUser);
$this->assertVote($tokenUser, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
}
public function testIsOwnerDeniedWhenTimesheetHasNoUser(): void
{
$user = self::getUser(1, User::ROLE_USER);
@@ -653,6 +640,99 @@ class TimesheetVoterTest extends AbstractVoterTestCase
$this->assertVote($requester, $timesheet, 'view', VoterInterface::ACCESS_GRANTED);
}
/**
* Reproduces GHSA-c6w6-57jj-62vh.
*
* After a user loses team access to a project, "restart" (start) and
* "duplicate" must NOT be allowed on one of their own historical timesheets:
* both operations derive a brand-new record from the old entry and would
* therefore create a new write under the now-unauthorized project/activity.
*
* The "_own_timesheet" branch in the voter currently short-circuits before
* checkTeamAccess*() runs, so this test FAILS on vulnerable code (the voter
* returns ACCESS_GRANTED) and documents the expected secure behaviour.
*/
public function testStartAndDuplicateDeniedAfterProjectAccessRevoked(): void
{
$owner = self::getUser(1, User::ROLE_USER);
// The project is now restricted to a team the owner is NOT a member of.
// This is the post-revocation state from the advisory's PoC.
$restrictedTeam = new Team('restricted after revocation');
$customer = new Customer('Acme');
$project = new Project();
$project->setCustomer($customer);
$project->addTeam($restrictedTeam);
$activity = new Activity();
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_DENIED);
}
/**
* Reproduces GHSA-c6w6-57jj-62vh for activity-level restriction.
*
* Even if the project is unrestricted, a restricted activity (a team the
* owner is no longer in) must block restart/duplicate.
*/
public function testStartAndDuplicateDeniedAfterActivityAccessRevoked(): void
{
$owner = self::getUser(1, User::ROLE_USER);
$customer = new Customer('Acme');
$project = new Project();
$project->setCustomer($customer);
$activity = new Activity();
$activity->setProject($project);
$activity->addTeam(new Team('restricted after revocation'));
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_DENIED);
}
/**
* Positive control for GHSA-c6w6-57jj-62vh: when the owner still has team
* access to the project and activity, restart/duplicate stay allowed.
* Guards the fix against over-restriction.
*/
public function testStartAndDuplicateGrantedWhenOwnerStillHasProjectAccess(): void
{
$owner = self::getUser(1, User::ROLE_USER);
$team = new Team('still a member');
$team->addUser($owner);
$customer = new Customer('Acme');
$project = new Project();
$project->setCustomer($customer);
$project->addTeam($team);
$activity = new Activity();
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_GRANTED);
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_GRANTED);
}
private static function getTimesheetFor(User $owner, ?Team $customerTeam = null, ?Team $projectTeam = null, ?Team $activityTeam = null): Timesheet
{
$customer = new Customer('Acme');

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\Team;
use App\Entity\User;
use App\Voter\UserVoter;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -119,4 +120,137 @@ class UserVoterTest extends AbstractVoterTestCase
self::assertEquals(VoterInterface::ACCESS_GRANTED, $sut->vote($token, $user, ['view_team_member']));
self::assertEquals(VoterInterface::ACCESS_DENIED, $sut->vote($token, $userMock, ['view_team_member']));
}
/**
* Even with the "<attribute>_other_profile" role permission, access to another user's
* profile must additionally pass the team-membership check in
* RolePermissionManager::checkUserAccess().
*/
public function testOtherProfileRequiresTeamRelation(): void
{
$teamlead = self::getUser(10, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$foreignUser = self::getUser(11, User::ROLE_USER);
$foreignUser->setEnabled(true);
// give the foreign user a team that the teamlead is NOT part of,
// so the special "subject has no teams" fallback does not kick in
$team = new Team('foreign team');
$foreignUser->addTeam($team);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
// the role permission "view_other_profile" exists, but the team relation is missing
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $foreignUser, ['view']));
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $foreignUser, ['edit']));
}
/**
* Same setup as above, but the current user IS teamlead of one of the subject's
* teams — access should then be granted.
*/
public function testOtherProfileGrantedWhenUserIsTeamleadOfSubject(): void
{
$teamlead = self::getUser(20, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$member = self::getUser(21, User::ROLE_USER);
$member->setEnabled(true);
$team = new Team('shared team');
$team->addUser($member);
$team->addTeamlead($teamlead);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['view']));
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['edit']));
}
/**
* Verify that disabled profiles can still be edited (e.g. to reactivate them or check historic data).
*/
public function testOtherProfileAllowedForDisabledSubject(): void
{
$teamlead = self::getUser(30, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$member = self::getUser(31, User::ROLE_USER);
$member->setEnabled(false);
$team = new Team('shared team');
$team->addUser($member);
$team->addTeamlead($teamlead);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['view']));
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['edit']));
}
/**
* Special case in checkUserAccess(): if the subject has no teams at all and the
* current user is a teamlead/admin, access is granted (small-installation case).
*/
public function testOtherProfileGrantedForTeamlessSubjectWhenUserIsTeamlead(): void
{
$teamlead = self::getUser(40, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$lonelyUser = self::getUser(41, User::ROLE_USER);
$lonelyUser->setEnabled(true);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $lonelyUser, ['view']));
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $lonelyUser, ['edit']));
}
/**
* Without the role permission "<attribute>_other_profile" the voter must deny,
* regardless of any team relation between user and subject.
*/
public function testOtherProfileDeniedWithoutRolePermissionEvenWithTeamRelation(): void
{
$teamlead = self::getUser(50, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$member = self::getUser(51, User::ROLE_USER);
$member->setEnabled(true);
$team = new Team('shared team');
$team->addUser($member);
$team->addTeamlead($teamlead);
// no "view_other_profile" permission for ROLE_TEAMLEAD
$permissions = [
'ROLE_TEAMLEAD' => ['view_own_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $member, ['view']));
}
}

View File

@@ -273,7 +273,7 @@ parameters:
-
message: "#^Parameter \\#5 \\$content of method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:request\\(\\) expects string\\|null, string\\|false given\\.$#"
count: 23
count: 25
path: API/TeamControllerTest.php
-