Run OSS Scanner image on Octane/FrankenPHP and bundle Gotenberg

Production runs as long-lived Octane workers in FrankenPHP worker mode, so state leaking between requests
(static properties, singletons, ...) is a real risk that php artisan serve cannot reproduce. Base the scanner image
on the production FrankenPHP image, add start-octane.sh (single worker, production Caddyfile) and describe this
class of issue in the threat model.

Bundle Gotenberg (Chromium only) so the PDF export tests pass offline and PDF rendering can be exercised.
This commit is contained in:
Constantin Graf
2026-10-09 15:34:33 +02:00
committed by Constantin Graf
parent 6301b0f4a1
commit 0c58896b00
5 changed files with 130 additions and 20 deletions

View File

@@ -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

16
.oss-scanner/start-gotenberg.sh Executable file
View File

@@ -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)"

37
.oss-scanner/start-octane.sh Executable file
View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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`).