mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-15 09:13:16 +01:00
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:
committed by
Constantin Graf
parent
6301b0f4a1
commit
0c58896b00
@@ -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
16
.oss-scanner/start-gotenberg.sh
Executable 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
37
.oss-scanner/start-octane.sh
Executable 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)"
|
||||
@@ -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)"
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user