Threat model, defensive measures and production hardening for the OakEngine Installer.
For the broader architecture see architecture.md. For the install manifest internals see install-manifest.md.
- architecture.md – module overview.
- install-manifest.md – manifest file format and path traversal protection.
- self-update.md – self-update pipeline and how it is locked down.
The installer has elevated filesystem and shell access on the target server:
- It writes files into a configurable target directory (often the document root or even the parent project root).
- For runner installs that target directory is the application root, including PHP files that get executed by the web server.
- It can run
shell_exec('php bin/console doctrine:migrations:migrate …')to touch the database. - It can clear
var/(Symfony cache). - It edits
.env.localin the target directory, which contains database credentials and theAPP_SECRET.
This means a successful attack on the installer is equivalent to remote code execution on the server that hosts it. Treat it accordingly.
- Set
passwordinconfig.php– use a long random string or apassword_hash()value. Without it, anyone who can reach the installer URL has full access. - Restrict the URL with web server rules – for Apache, see
src/.htaccess; for Nginx, deny access by default and only allow from specific IPs. - HTTPS only – the password and the bearer token for the package endpoint travel in HTTP headers. Never serve the installer over plain HTTP.
- Rotate
APP_SECRETregularly – it is the Symfony secret used for CSRF tokens and signed URIs. Rotate via the dashboard after every privilege change. - Keep the install UUID stable unless you mean to reset the endpoint’s tracking of the installation.
- Drop the installer when you are done – the install step is one-shot. Leaving the installer on a production server expands the attack surface for no benefit.
- Review
exclude_folders/exclude_filesinconfig.phpto ensure the runner tarball cannot overwrite your custom secrets, CI configs, or.env.local.
InstallManifestManager::deleteStaleFilesAndEmptyDirs() rejects any relative path that contains a .. segment:
if ($this->containsTraversal($normalizedPath)) {
continue;
}containsTraversal() splits on / and checks for an exact .. entry. So ../etc/passwd and foo/../bar are silently skipped, while legitimate filenames like foo..bar or ..hidden are accepted. The check is necessary because the path comes from a JSON manifest that an attacker could otherwise tamper with via config.php.
The earlier implementation resolved paths via realpath() to check that files stayed inside the target directory. That approach was replaced by the simpler ..-segment rejection because it does not depend on filesystem state and is fully testable.
ProjectPackageArchiveExtractor extracts with the --no-same-owner and --no-same-permissions flags when using the tar binary, and applies extractTo(..., null, true) (overwrite) when using the PHP PharData fallback. The third argument to PharData::extractTo() ensures existing files are overwritten.
updateUpdaterFromTag() does not copy every file from the GitHub archive. Only files that pass isAllowedUpdaterFile() are written into the installer directory. In particular, config.php is never overwritten – the installer preserves the user configuration across updates.
If a target directory contains no install manifest yet, the first install falls back to cleanTargetDirectory(), which preserves:
runner/anddata/subdirectories (they have their own manifests once installed).public/update/(the installer itself when co-located with the runner)..env.local(runtime configuration).
Anything outside this whitelist is removed. As soon as the first install has produced a manifest, every subsequent install uses the diff workflow instead and never touches anything that was not tracked by the manifest itself.
The package endpoint can be protected with a bearer token via project_api_token. The installer sends it as Authorization: Bearer …. Treat this token as a credential: store it in config.php, never commit config.php to version control, and rotate it if it leaks.
The install UUID is sent as both a query parameter (install_uuid) and a header (X-Install-UUID). It is not a secret – it is a correlation identifier. Do not rely on it for authentication.
github_token is optional and only used for the installer self-update. An authenticated request has a much higher rate limit than an anonymous one (5000 vs 60 requests per hour).
The installer does not implement its own crypto. It relies on:
- HTTPS for transport encryption.
password_hash()/password_verify()for the optional UI password (any value inconfig.phpthat starts with$2y$or similar is treated as a hash, anything else as plain text).APP_SECRETgeneration viarandom_bytes(16)plus a hex encoding (seeAppSecretManager).install_uuidgenerated as UUID v7 (InstallUuidManager).
If you need a stronger password hash, set password to password_hash('your-strong-password', PASSWORD_BCRYPT).
The installer uses PHP sessions for the password gate. Every state-changing POST handler is reachable only from an authenticated session. The Symfony APP_SECRET from .env.local is used for Symfony's own CSRF protection when the runner is installed, but the installer UI itself does not issue CSRF tokens – instead it relies on the password gate and the SameSite=Lax cookie defaults.
If you put the installer behind a reverse proxy, make sure the proxy does not strip the Set-Cookie header or downgrade the session cookie to a cross-site context.
The installer writes a structured, append-only log file named oak-installer.log. Every install, cleanup, stale-file removal, failed deletion and package summary is recorded there with a UTC timestamp, level and key=value context so operators can audit exactly what changed on the server.
The log directory is resolved once at request time by installerLogBaseDirectory() and is intentionally kept outside the installed project (and therefore outside the <target>/var/ tree that clearCacheDirectory() wipes):
- If
log_directoryis set inconfig.php, that path is used (absolute paths are used as-is, relative paths are resolved against the installer root). - Otherwise the installer falls back to
<installer-root>/logs, i.e. alogs/folder next to the installer's ownsrc/directory.
Because clearing the application cache only ever deletes <target>/var/, the installer log is never deleted by a cache clear, a first-install cleanup, or any other in-project operation. As an additional safety net, clearCacheDirectory() explicitly preserves any top-level log/logs directory inside the cache tree, so runtime logs (for example Symfony's var/log) survive a cache clear too.
Logging is best-effort and never throws: if the log directory cannot be created or written, the failure is swallowed so that logging can never break an installation.
Errors are also shown on the rendered error page. For additional audit trails, rely on your web server's access log and the Symfony log inside <target>/var/log/ (once the runner is installed).
Reviewed surface and findings. Items marked fixed are addressed in this revision; the rest are recommendations.
- No CSRF tokens on state-changing POSTs (
src/app/Authenticator.php,src/app/InstallerApplication.php): the UI relies on the password gate plus theSameSite=Laxsession cookie. A malicious site that can make an authenticated admin's browser submit a form could trigger installs, cache clears, migrations,.env.localedits or a self-update.SameSite=Laxblocks most cross-site POSTs, but an explicit CSRF token per form is the robust fix. Recommendation. - No session ID regeneration on login (
src/app/Authenticator.php:50): after successful authentication the existing session ID is reused, which is a classic session-fixation vector. Recommendation: callsession_regenerate_id(true)when settingoak_installer_authenticated. - No brute-force protection on the login form. Recommendation: add rate limiting (e.g. via the web server / fail2ban) or a lockout.
show_versions_before_loginleaks installer/runner versions on the login screen when enabled (off by default). Keep it off in production.
- Install-log location was wiped by cache clear – fixed. The log now lives outside
<target>/var/(see Logging above) andclearCacheDirectory()preserveslog/logsdirectories. - Path traversal in stale-file cleanup is rejected:
InstallManifestManager::containsTraversal()drops any relative path containing a..segment. - Self-update whitelist (
src/app/InstallerUpdater.php): only files matchingisAllowedUpdaterFile()are copied during a self-update;config.phpis never overwritten. - Package archive extraction (
src/app/ProjectPackageArchiveExtractor.php): extraction trusts the configured package endpoint. A compromised endpoint could craft archive entries; consider adding an explicit..-segment rejection to the copy step as defence in depth (the trusted-endpoint model currently mitigates this).
shell_execfor migrations (src/app/InstallerApplication.php): the console path comes from trusted config and is passed throughescapeshellarg(). The broad surface is the price of the feature; restrict installer access accordingly.APP_SECRETis partially echoed (≈8 chars) in the configuration dashboard. Acceptable behind authentication, but treat the dashboard as privileged..htaccessgrants direct access to every.phpfile.config.phponlyreturns an array (executing it leaks nothing), but denying direct access toconfig.php/config.example.phpis a cheap hardening.
Set password, serve over HTTPS only, restrict the installer URL by IP, keep log_directory outside the document root, and remove the installer once the installation is finished.