diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile index e010dff1..e09c5d78 100644 --- a/.oss-scanner/Dockerfile +++ b/.oss-scanner/Dockerfile @@ -1,41 +1,68 @@ # Build environment for Anthropic's OSS Scanner (https://github.com/anthropics/oss-scanner). # The scanner builds this image with the repository root as the build context and then audits it without network # access, so everything needed to run the app and its test suite (PHP + Composer deps, Node deps + built frontend, -# a local PostgreSQL) is installed here. +# a local PostgreSQL and a local Gotenberg for PDF rendering) is installed here. +# +# The base image is the FrankenPHP image the production image (docker/prod/Dockerfile) is built on: in production the +# app runs as long-lived Laravel Octane workers in FrankenPHP worker mode, not as one PHP process per request. # # Inside the finished image: # .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app) -# php artisan test run the PHPUnit suite -# php artisan serve run the app on http://127.0.0.1:8000 (after `php artisan migrate --seed`) +# .oss-scanner/start-gotenberg.sh start the local Gotenberg server (required for PDF exports) +# php artisan test run the PHPUnit suite (needs PostgreSQL and Gotenberg running) +# .oss-scanner/start-octane.sh run the app like production on http://127.0.0.1:8000 (starts everything above) -FROM node:20-bookworm-slim AS node +ARG FRANKENPHP_VERSION=1.11 +ARG PHP_VERSION=8.3 -FROM php:8.3-cli-bookworm +FROM node:20-trixie-slim AS node + +FROM gotenberg/gotenberg:8 AS gotenberg + +FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} ENV DEBIAN_FRONTEND=noninteractive \ COMPOSER_ALLOW_SUPERUSER=1 \ COMPOSER_NO_INTERACTION=1 \ + OCTANE_SERVER=frankenphp \ TZ=UTC -COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer COPY --from=node /usr/local/bin/node /usr/local/bin/node COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx -# PostgreSQL 15 (Debian bookworm default) is the database solidtime runs and is tested against. +# PostgreSQL 17 (Debian trixie default) is one of the versions solidtime is tested against in CI. +# chromium, qpdf and exiftool are what Gotenberg needs for HTML to PDF rendering. RUN apt-get update \ && apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \ - && install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap \ + chromium fonts-liberation qpdf libimage-exiftool-perl \ + && install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap apcu \ && rm -rf /var/lib/apt/lists/* \ && echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini" +# Gotenberg 8 (the PDF renderer, same image as in CI). solidtime only uses its Chromium HTML to PDF route, so LibreOffice +# and pdftk are not installed: Gotenberg only checks at startup that their binaries exist, start-gotenberg.sh disables +# the LibreOffice routes and the Chromium route does not use pdftk. +COPY --from=gotenberg /usr/bin/gotenberg /usr/local/bin/gotenberg +COPY --from=gotenberg /usr/bin/pdfcpu /usr/local/bin/pdfcpu +COPY --from=gotenberg /opt/gotenberg /opt/gotenberg +ENV CHROMIUM_BIN_PATH=/usr/bin/chromium \ + CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data \ + GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions \ + QPDF_BIN_PATH=/usr/bin/qpdf \ + EXIFTOOL_BIN_PATH=/usr/bin/exiftool \ + PDFCPU_BIN_PATH=/usr/local/bin/pdfcpu \ + LIBREOFFICE_BIN_PATH=/bin/false \ + UNOCONVERTER_BIN_PATH=/bin/false \ + PDFTK_BIN_PATH=/bin/false + # Database matching .env.ci: user root / password root, database laravel. -RUN pg_ctlcluster 15 main start \ +RUN pg_ctlcluster 17 main start \ && runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \ && runuser -u postgres -- createdb -O root laravel \ - && pg_ctlcluster 15 main stop + && pg_ctlcluster 17 main stop # scanner contract: the checkout lives inside the image, at /src COPY . /src @@ -48,9 +75,12 @@ RUN composer install --prefer-dist \ RUN cp .env.ci .env \ && php artisan key:generate \ && php artisan passport:keys --force \ - && chmod +x .oss-scanner/start-postgres.sh + && php artisan octane:install --server=frankenphp --no-interaction \ + && chmod +x .oss-scanner/*.sh # Run the test suite so the image is known to work, but do not fail the build on test failures. RUN .oss-scanner/start-postgres.sh \ + && .oss-scanner/start-gotenberg.sh \ && (php artisan test || echo "WARNING: PHPUnit reported failures") \ - && pg_ctlcluster 15 main stop + && kill "$(cat /tmp/gotenberg.pid)" \ + && pg_ctlcluster 17 main stop diff --git a/.oss-scanner/start-gotenberg.sh b/.oss-scanner/start-gotenberg.sh new file mode 100755 index 00000000..2186f4fa --- /dev/null +++ b/.oss-scanner/start-gotenberg.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Start the local Gotenberg server (PDF rendering) on 127.0.0.1:3000, matching GOTENBERG_URL in .env.ci. +# Only the Chromium routes are needed by solidtime, see .oss-scanner/Dockerfile. Stop: kill "$(cat /tmp/gotenberg.pid)" +set -euo pipefail + +if ! curl -fs http://127.0.0.1:3000/health >/dev/null; then + nohup gotenberg \ + --api-port=3000 \ + --libreoffice-disable-routes \ + --libreoffice-auto-start=false \ + --log-level=warn \ + >/tmp/gotenberg.log 2>&1 & + echo $! >/tmp/gotenberg.pid + until curl -fs http://127.0.0.1:3000/health >/dev/null; do sleep 0.5; done +fi +echo "Gotenberg is running on http://127.0.0.1:3000 (log: /tmp/gotenberg.log)" diff --git a/.oss-scanner/start-octane.sh b/.oss-scanner/start-octane.sh new file mode 100755 index 00000000..13fa8e92 --- /dev/null +++ b/.oss-scanner/start-octane.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run the app the way production does: Laravel Octane on FrankenPHP in worker mode, with the production Caddyfile. +# A worker boots the app once and then serves many requests, so state kept in memory (static properties, singletons, +# changed config, ...) survives from one request to the next. With a single worker, consecutive requests always hit the +# same worker, so such leaks between requests (e.g. between users of different organizations) reproduce reliably. +# +# Usage: .oss-scanner/start-octane.sh (OCTANE_WORKERS and OCTANE_MAX_REQUESTS can be overridden) +# Stop: php artisan octane:stop +# After changing PHP code, run `php artisan octane:reload`: workers keep the code they booted with. +set -euo pipefail +cd "$(dirname "$0")/.." + +.oss-scanner/start-postgres.sh +.oss-scanner/start-gotenberg.sh + +php artisan migrate --force +if [ "$(PGPASSWORD=root psql -h 127.0.0.1 -U root -d laravel -tAc 'SELECT count(*) FROM users')" = "0" ]; then + php artisan db:seed --force +fi +php artisan optimize:clear >/dev/null + +nohup php artisan octane:frankenphp \ + --host=127.0.0.1 \ + --port=8000 \ + --workers="${OCTANE_WORKERS:-1}" \ + --max-requests="${OCTANE_MAX_REQUESTS:-10000}" \ + --caddyfile=docker/prod/deployment/octane/FrankenPHP/Caddyfile \ + >/tmp/octane.log 2>&1 & + +until curl -fs -o /dev/null http://127.0.0.1:8000/login; do + if ! kill -0 $! 2>/dev/null; then + cat /tmp/octane.log + exit 1 + fi + sleep 0.5 +done +echo "solidtime is running on http://127.0.0.1:8000 with Octane/FrankenPHP (log: /tmp/octane.log)" diff --git a/.oss-scanner/start-postgres.sh b/.oss-scanner/start-postgres.sh index a7f87a63..bcbedc61 100755 --- a/.oss-scanner/start-postgres.sh +++ b/.oss-scanner/start-postgres.sh @@ -2,6 +2,6 @@ # Start the local PostgreSQL server used by the test suite and the app (see .oss-scanner/Dockerfile). set -euo pipefail -pg_ctlcluster 15 main start 2>/dev/null || true +pg_ctlcluster 17 main start 2>/dev/null || true until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 0.5; done echo "PostgreSQL is running on 127.0.0.1:5432 (user root, password root, database laravel)" diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index fe848603..a8004211 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -22,6 +22,21 @@ explicitly allow it). - Operators of a self-hosted instance (shell, database, environment, filesystem access) are trusted. - Everyone else, including organization owners and admins when acting outside their own organization, is untrusted. +## Runtime: long-lived Octane workers + +In production (the hosted SaaS and the official Docker image, `docker/prod/`) the app does **not** run as one PHP +process per request. It runs on Laravel Octane with FrankenPHP in worker mode: each worker boots the application once +and then serves many requests from different users and organizations. Anything kept in memory survives from one request +to the next unless Octane resets it (`config/octane.php` lists what is reset). This includes static properties and +static caches, container bindings registered with `singleton()` instead of `scoped()` (see +`app/Providers/AppServiceProvider.php`), objects captured by those singletons, runtime `config()` / locale / timezone +changes, macros and event listeners registered during a request, and state in third-party packages. + +Request A leaving state behind that request B (another user, possibly of another organization) then sees or is +affected by is in scope, rated by its impact like any other issue (see severity below). The PHPUnit suite cannot +show this class of bug, because it boots a fresh application for every test. It has to be reproduced over HTTP against +the Octane server, see "How to exercise it". + ## Where untrusted input enters All authenticated users, including employees of any organization and anyone who self-registers (registration is open @@ -53,19 +68,30 @@ Less important / out of scope: ## How to exercise it - `.oss-scanner/start-postgres.sh` starts the local PostgreSQL server (user `root`, password `root`, db `laravel`). -- `php artisan test` runs the PHPUnit suite. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users, +- `.oss-scanner/start-gotenberg.sh` starts the local Gotenberg server (PDF rendering via headless Chromium) on + http://127.0.0.1:3000, so the PDF export code path, including what Chromium does with the rendered HTML, can be + exercised. +- `php artisan test` runs the PHPUnit suite (start PostgreSQL and Gotenberg first); all tests are expected to pass. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users, organizations and members with factories and call the API with a given role; they are the quickest way to write a reproducer. Example: `php artisan test --filter=TimeEntryEndpointTest`. -- To run the app: `php artisan migrate:fresh --seed && php artisan serve` (http://127.0.0.1:8000). Note that the - test suite and the app share the same database. -- There is no network: Gotenberg (PDF generation) and mail delivery are not available. Mail uses the `array` - driver in tests. The 8 PDF export tests in `TimeEntryEndpointTest` fail for this reason; that is expected. +- To run the app like production: `.oss-scanner/start-octane.sh` (http://127.0.0.1:8000). It starts PostgreSQL and + Gotenberg, migrates (and seeds an empty database, see `database/seeders/DatabaseSeeder.php` for the users) and runs + Octane/FrankenPHP with the production Caddyfile and a **single worker**, so consecutive requests always hit the same + worker and leaks between requests reproduce reliably. Workers keep the code they booted with: after changing PHP + code run `php artisan octane:reload`. Stop it with `php artisan octane:stop`. Note that the test suite and the app + share the same database, so `php artisan test` wipes the app's data. +- A reproducer for a leak between requests is a script that sends request A (e.g. as a member of organization X) and + then request B (as a user of organization Y) to the running Octane server and shows that B observes A's state. API + requests can be authenticated with a personal access token, e.g. created with + `php artisan tinker --execute="echo App\Models\User::where('email', '...')->first()->createToken('t')->accessToken;"`. +- There is no network: mail delivery is not available (mail uses the `array` driver in tests), and remote resources + referenced by PDF templates (e.g. fonts from fonts.bunny.net) fail to load, so PDFs fall back to local fonts. ## How we rate severity - **Critical**: unauthenticated access to other users' data or accounts; authentication bypass; remote code execution; SQL injection reachable by any registered user; reading or writing data of an organization the attacker is not a - member of. + member of, including through state leaking between requests in an Octane worker. - **High**: privilege escalation within an organization (e.g. employee to admin/owner, or performing admin-only actions); access to data the role must not see (other members' time entries, billable rates, member emails) when the organization settings do not allow it; stored XSS that executes in another user's session; SSRF via PDF @@ -91,5 +117,6 @@ Please do not report (see also `SECURITY.md`): ## Reports and patches Please include the affected endpoint or code path, the attacker's role and the victim, a PHPUnit test (in the style of -`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue, and a minimal patch that follows the existing patterns +`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue (for leaks between requests: a script against +`.oss-scanner/start-octane.sh` instead), and a minimal patch that follows the existing patterns (authorisation in form requests/controllers via `PermissionStore`).