This document describes the security analysis performed on Cronmanager, mapping findings to OWASP Top 10 (2021) and other industry standards. It covers the controls already implemented and the residual risks with recommended next steps.
For the overall security architecture see TECHNICAL.md – Security Model.
- Scope
- Threat Model
- OWASP Top 10 (2021) Mapping
- Findings and Status
- Additional Controls Implemented
- Residual Risks and Recommendations
- Security Checklist
The analysis covers:
| Layer | Scope |
|---|---|
| Web UI | PHP source, templates, session management, routing (/opt/dev/cronmanager/web/) |
| Host Agent | PHP source, HMAC validation, crontab operations (/opt/dev/cronmanager/agent/) |
| Database | Schema, query patterns, credential handling |
| cron-wrapper.sh | Bash script injected into crontab entries |
| Deployment | deploy.sh, deploy.env, credential files |
| Docker / host | Container configuration, network exposure |
| Asset | Confidentiality | Integrity | Availability |
|---|---|---|---|
| User credentials (hashed) | High | High | Medium |
| Crontab entries (system commands) | Medium | Critical | High |
| Execution history / output | Medium | Medium | Medium |
| HMAC shared secret | Critical | High | Medium |
| Database credentials | Critical | High | Medium |
| API keys (plain text, shown once) | Critical | High | Low |
| Actor | Vector |
|---|---|
| Unauthenticated external attacker | Public-facing web UI |
| Authenticated low-privilege user | In-app privilege escalation |
| Attacker with local host access | Reads config files / memory |
| Malicious cron payload author | Injects commands through job editing |
| Compromised API key holder | Calls REST API with a stolen or leaked key |
| OWASP ID | Category | Status |
|---|---|---|
| A01 | Broken Access Control | Mitigated |
| A02 | Cryptographic Failures | Mitigated |
| A03 | Injection (SQL, OS) | Mitigated |
| A04 | Insecure Design | Low risk – design reviewed |
| A05 | Security Misconfiguration | Mitigated |
| A06 | Vulnerable and Outdated Components | Automated (CI) – composer audit |
| A07 | Identification and Authentication Failures | Mitigated |
| A08 | Software and Data Integrity Failures | Low risk |
| A09 | Security Logging and Monitoring Failures | Mitigated |
| A10 | Server-Side Request Forgery | Not applicable |
Risk: An authenticated viewer could access admin-only pages (user management, cron create/edit/delete).
Root cause: Routes are registered with an optional minimum-role parameter. Before the fix,
templates checked $user['role'] === 'admin' using a template variable that could be overridden
by sub-templates (e.g., detail.php sets $user as a string for an unrelated purpose, leaking
into the layout scope).
Fix applied (web/templates/layout.php):
- All admin navigation visibility checks replaced with
SessionManager::hasRole('admin'), which reads directly from the authenticated session rather than a possibly polluted template variable. - The Router (
Router.php) enforces the role on every request before dispatching.
Verification: Every protected route calls SessionManager::hasRole($requiredRole) server-side.
Template role checks are defence-in-depth only (UI hiding, not access control).
Risk: An admin user could accidentally (or maliciously) demote or delete their own account, leaving the system without an administrator.
Fix: UserController rejects requests where the target user ID equals the currently logged-in
user ID with HTTP 403.
Risk: A malicious page could silently trigger cron creation, deletion, or user role changes on behalf of an authenticated user (Cross-Site Request Forgery).
Fix applied:
SessionManager::getCsrfToken()generates a 64-character cryptographically random hex token (bin2hex(random_bytes(32))) stored in the session on first access.SessionManager::validateCsrfToken(string $submitted): boolcompares withhash_equals()to prevent timing attacks.Router::dispatch()validates$_POST['_csrf']for allPOST,PUT,PATCH, andDELETErequests on protected routes.- All POST forms in templates include
<input type="hidden" name="_csrf" value="<?= $csrf_token ?>">. BaseController::render()automatically injectscsrf_tokeninto every template data array.
Note on public POST routes: The login and setup forms (POST /login, POST /setup) are not
behind the router's CSRF check because they cannot rely on a pre-authenticated session token.
Their CSRF risk is lower: a forged login at worst logs the attacker in as themselves, and setup is
a one-time event. Nonetheless, the forms include the CSRF field for consistency and defence-in-depth.
Risk: Passwords stored in recoverable form.
Implementation: LocalAuthProvider uses PHP's password_hash() with PASSWORD_BCRYPT (or
PASSWORD_ARGON2ID where available, depending on configuration). password_verify() is used for
all comparisons. Plaintext passwords are never written to logs or databases.
Risk: Weak or default HMAC secrets allow request forgery between the web container and the host agent.
Fix applied (web/index.php, agent/agent.php):
- At startup, the application checks the configured
hmac_secret. - If it is empty or equals the example placeholder (
change-me-to-a-secure-random-string), aCRITICALlog entry is emitted. - If it is shorter than 32 characters, a
WARNINGis emitted. - The recommended generation command (
openssl rand -hex 32) is included in both the log message and the configuration example.
Risk: Authorization code interception.
Implementation: OidcAuthProvider generates a cryptographically random PKCE code_verifier
(64 bytes, base64url-encoded), computes code_challenge = BASE64URL(SHA256(code_verifier)), and
stores both verifier and state in the PHP session. Both are verified before the token exchange.
Risk: HTTP between components exposes credentials or commands.
Status / recommendation:
- Browser → Web UI: HTTPS should be configured at the reverse-proxy / Docker ingress level. The application itself does not handle TLS termination.
- Web container → Host agent: Uses HTTP. The exposure depends on the deployment mode:
- Host-agent mode: The web container reaches the agent via
host.docker.internal(Docker bridge to the host's loopback interface, not externally routable). - Docker mode (file-mounted source): Traffic stays on the private
cronmanager-internalDocker network and never leaves the host. - Docker Hub mode (
docker-compose-full.yml): All three services (web, agent, MariaDB) share a private named Docker network; web→agent traffic is container-to-container and never leaves the host. - In all cases the HMAC signature ensures integrity and authenticity of requests even without TLS on this internal hop.
- Host-agent mode: The web container reaches the agent via
- Recommendation: Add TLS to the host agent listener for defence-in-depth if the Docker network is shared with untrusted containers.
Risk: Attacker-controlled data interpolated into SQL queries.
Implementation: Every database interaction uses PDO prepared statements with named placeholders. No raw string concatenation occurs in SQL queries anywhere in the codebase.
Risk: Cron job command fields are written verbatim into system crontabs.
Context: Cronmanager's purpose is to manage arbitrary system commands. Only authenticated
admin users can create or edit jobs. The host agent writes commands to crontab files; it does
not eval or exec them — the cron daemon interprets them.
Controls in place:
- Only admin-role users can create or modify cron job commands (role enforcement, A01).
- The HMAC-secured channel prevents un-authenticated writes to the agent.
cron-wrapper.shruns as the crontab owner (not root by default).
Recommendation: Consider a UI-level allowlist or regex validation on the command field to
prevent accidental typos with dangerous shell constructs (e.g., ; rm -rf /).
Risk: Stored cron job descriptions or user input rendered as raw HTML.
Fix: All dynamic output in PHP templates passes through:
htmlspecialchars($value, ENT_QUOTES, 'UTF-8')Verified across all template files. No echo $variable without escaping exists.
Risk: Missing security headers allow clickjacking, MIME sniffing, and XSS escalation.
Fix applied (web/index.php, sent before any output):
| Header | Value | Protection |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME-type sniffing |
X-Frame-Options |
DENY |
Prevents clickjacking via <iframe> |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer leakage |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=() |
Disables unused browser APIs |
Content-Security-Policy |
See below | Defence-in-depth against XSS |
CSP value:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none'
Known limitation: 'unsafe-inline' for scripts is required because the Tailwind dark-mode
detection snippet and tailwind.config block are inline <script> elements in layout.php.
For a stricter CSP, these should be extracted to external files and a nonce-based CSP applied.
This is a recommended future improvement but does not represent a critical risk in a homelab
deployment model.
See Finding 2.2 (startup HMAC secret validation).
Additional control: The deploy.env.example and config.json.example files explicitly
contain placeholder values. The deployment script prints a reminder to update secrets after a
full deployment.
Risk: Stack traces or internal paths exposed in HTTP responses.
Implementation: The top-level catch block in index.php logs full exception details via
Monolog but returns only a generic "500 – Internal Server Error" page to the browser.
PHP error display (display_errors) must be set to Off in php.ini for production — this
is a deployment configuration requirement, not enforced by the application code.
Status: Automated – composer audit --no-dev runs in CI on every push.
The application uses the following third-party libraries (via shared /opt/phplib/vendor/):
| Library | Version constraint | Purpose |
|---|---|---|
hassankhan/config |
^2.1 |
Configuration loading |
monolog/monolog |
^3.6 |
Logging |
guzzlehttp/guzzle |
^7.8 |
HTTP client (HostAgentClient) |
phpmailer/phpmailer |
^6.8 |
Email notifications |
Automated control: composer audit --no-dev is executed on every push and pull
request via the dependency-audit job in .github/workflows/security.yml. The job
fails (blocking CI) if any production dependency has a known CVE or PHP Security
Advisory. Dev-only dependencies (PHPUnit, Mockery, etc.) are excluded.
Recommendations:
- Pin minor versions in
composer.jsonand update deliberately with testing. - Subscribe to security advisories for the listed packages.
Risk: Brute-force or credential-stuffing attacks against the login form.
Fix applied (web/src/Session/SessionManager.php, web/src/Controller/AuthController.php):
- Per-IP tracking with
hash('sha256', $ip)as the session key (no plaintext IP in session data). - Threshold: 5 failed attempts.
- Lockout duration: 15 minutes (configurable via
RATE_LOCK_SECONDS). - On lockout: HTTP redirect to
/loginwith a flash message showing remaining minutes. - Rate tracking is stored in the PHP session.
Known limitation: Session-based rate limiting is scoped to a single PHP session. An attacker using a fresh browser (new session) or different IPs bypasses the counter. For production hardening, use a shared store (APCu, Redis, or a database table) keyed by IP address.
Risk: Timing or error message differences reveal whether a username exists.
Fix applied:
- Failed login always returns the same flash error key (
login_error_credentials) regardless of whether the username was found or the password was wrong. - Failed login logs
hash('sha256', $username)instead of the plaintext username, so log analysis cannot enumerate valid usernames from the log file.
Risk: An attacker pre-sets a known session ID before login.
Implementation: SessionManager::login() calls session_regenerate_id(true) immediately
after successful authentication, invalidating the old session and preventing fixation.
Implementation: SessionManager::start() sets the following before session_start():
| INI setting | Value | Reason |
|---|---|---|
session.cookie_httponly |
1 |
Prevents JavaScript access to the session cookie |
session.cookie_samesite |
Lax |
Mitigates CSRF from cross-origin requests |
session.cookie_secure |
1 (when HTTPS) |
Prevents transmission over plain HTTP |
session.use_strict_mode |
1 |
Rejects externally supplied session IDs |
Risk: External callers accessing the REST API without adequate authentication or with overly broad permissions — equivalent to granting unconstrained admin access to scripts.
Implementation (src/Auth/ApiKeyMiddleware.php, src/Auth/ApiKeyRepository.php):
- Key format:
cm_prefix + 40 cryptographically random URL-safe base64 characters (random_bytes(30)). Thecm_prefix makes leaked keys identifiable. - Storage: Only
sha256(plain_key)is stored in the database. The plain-text key is shown exactly once in the UI and cannot be recovered. Leaking the database does not expose usable keys. - Scope-based least-privilege: Each key is granted a minimal set of the 8 available
scopes. A
read-onlykey has no write or execute access even if the creating user is an admin. - Scope inheritance: A user can only grant scopes their own role permits
(
view-users may not grantjobs:write,maintenance:write,settings:write, orsettings:read). - IP whitelist: Optional CIDR-notation allowlist enforced by
ApiKey::isIpAllowed()usinginet_ptonbit-comparison. Requests from unlisted IPs receive HTTP 403. - Expiry: Optional
expires_at; expired keys receive HTTP 401. - Non-blocking
last_used_at: Updated viaregister_shutdown_function()so the timestamp is recorded without adding latency to the API response. - No session, no CSRF: The API is fully stateless. Cookie-based CSRF does not apply;
the
Authorizationheader is the only credential. - HMAC separate concern: The HMAC channel is between the web container and the internal agent — it is invisible to and independent of external API callers.
Residual risk: A stolen API key grants access until it is revoked or expires. There is no automatic revocation on suspicious usage patterns. Mitigation: use narrow scopes, short expiry dates, and IP whitelists for keys used by automated scripts.
Events logged via Monolog:
| Event | Level | Details logged |
|---|---|---|
| Successful login | INFO |
username, IP |
| Failed login | INFO |
sha256(username), IP |
| Login blocked (rate limit) | WARNING |
IP |
| Login exception | ERROR |
exception message |
| Logout | INFO |
username |
| CSRF validation failure | WARNING |
method, path, IP |
| OIDC callback error | WARNING / ERROR |
error code, description |
| 403 access denied | WARNING |
method, path, username |
| Unhandled exception | ERROR |
exception class, message, file, line, trace |
| Weak/default HMAC secret | CRITICAL / WARNING |
at startup |
Log configuration: RotatingFileHandler with a configurable path (default
/opt/cronmanager/agent/log/cronmanager-agent.log (agent) and /var/www/log/cronmanager-web.log (web)) and 30-day retention. Log level is configurable (default DEBUG
in development, should be WARNING or higher in production).
Since v4.0.0 a dedicated GitHub Actions workflow (.github/workflows/security.yml)
runs on every push and pull request with two parallel jobs:
composer audit --no-dev
Checks all production Composer dependencies against the PHP Security Advisories database. Fails when any installed package version has a known CVE. Dev dependencies are excluded.
OWASP coverage: A06 – Vulnerable and Outdated Components
semgrep scan --config p/php --config p/owasp-top-ten --error .
Semgrep OSS with two rule sets from the public Semgrep Registry:
| Rule set | Focus |
|---|---|
p/php |
PHP-specific: injection, XSS, path traversal, open redirect, dangerous functions |
p/owasp-top-ten |
OWASP Top 10 (2021) mapped rules across PHP and other languages |
OWASP coverage: A01 (access control, path traversal), A02 (weak crypto, hardcoded secrets), A03 (SQL/command/XSS injection), A07 (auth failures), A10 (SSRF)
The job fails on any finding (--error). Excluded paths are listed in .semgrepignore
(vendor/, tests/, compiled frontend assets, SQL migrations).
Suppressing a false positive per line:
exec($bgCmd . ' &'); // nosemgrep: php.lang.security.exec-use.exec-useOptional: set the SEMGREP_APP_TOKEN repository secret (free semgrep.dev account)
to enable full Registry access and upload findings to the Semgrep dashboard for trend
tracking over time.
The following controls were added that do not map directly to a single OWASP category but represent security best practices:
| Control | Location | Description |
|---|---|---|
| Constant-time HMAC comparison | HmacValidator::validate() |
hash_equals() prevents timing attacks |
| Constant-time CSRF comparison | SessionManager::validateCsrfToken() |
hash_equals() prevents timing attacks |
| PKCE for OIDC | OidcAuthProvider |
Prevents authorization code interception |
| State parameter for OIDC | OidcAuthProvider |
Prevents CSRF on OIDC callback |
| Parameterised queries (PDO) | All DB access | Prevents SQL injection |
extract(EXTR_SKIP) |
Template rendering | Prevents template variable injection from data |
| Output escaping | All templates | htmlspecialchars(ENT_QUOTES, UTF-8) on all dynamic output |
| cron-wrapper HMAC | cron-wrapper.sh |
Execution reports are signed; the agent validates them |
| Startup orphan cleanup | agent/bin/startup-cleanup.php |
On agent restart, detects and resolves execution_log rows left "running" by a previous crash/stop; prevents ghost "running" entries in the UI after VM maintenance |
| API key hashing | ApiKeyRepository::create() |
Only sha256(plain_key) stored; plain text shown once and never persisted |
| API key scope enforcement | ApiKeyMiddleware::authenticate() |
Required scope checked on every call; missing scope → HTTP 403 |
| API key scope inheritance | ScopeHelper::allowedScopesForRole() |
Users may not grant scopes beyond their own role's permissions |
| API key IP whitelist | ApiKey::isIpAllowed() |
CIDR-based IP restriction enforced via inet_pton bit-comparison |
| API key non-blocking timestamp | register_shutdown_function() |
last_used_at updated after response is sent; no latency impact |
The following items are known limitations or recommended improvements that were not addressed in this iteration. They are ordered by priority.
| # | Risk | Recommendation |
|---|---|---|
| R1 | Session-scoped rate limiting can be bypassed by new sessions | Replace with shared-store (APCu / Redis / DB) rate limiting keyed by IP |
| R2 | 'unsafe-inline' in CSP |
Extract inline scripts to external files; use nonce-based CSP |
| R3 | HTTP (not HTTPS) on web UI by default | Configure TLS at the ingress/proxy level; enforce Strict-Transport-Security |
| # | Risk | Recommendation |
|---|---|---|
| R4 | No CSRF protection on POST /login |
Add CSRF validation to public POST routes as additional defence-in-depth |
| R5 | Cron command field allows arbitrary shell syntax | Add UI-level soft validation (warn on dangerous patterns like ;, &&, ` |
| R6 | display_errors must be disabled in PHP runtime |
Document as explicit deployment prerequisite; consider adding a startup check |
| # | Risk | Recommendation |
|---|---|---|
| R7 | No HTTP Strict Transport Security header | Add Strict-Transport-Security: max-age=63072000 when HTTPS is confirmed |
| R8 | Composer dependency versions unpinned | Pin exact versions with composer.lock |
| R9 | Log file permissions | Ensure log directory is not world-readable; logs contain IP addresses (personal data) |
| R10 | No account lockout for OIDC path | OIDC brute-force is the IdP's responsibility; document this clearly |
Use this checklist when deploying a new instance or reviewing after changes.
- Generated a fresh HMAC secret (
openssl rand -hex 32) in agent and web config - Changed database passwords from example values
- Set
DEPLOY_TYPEand SSH host indeploy.env - Verified
php.inihasdisplay_errors = Offandexpose_php = Off - TLS/HTTPS configured at the ingress level (reverse proxy, nginx, Caddy, etc.)
- Confirm first-run setup created the admin account and
/setupredirects to/login - Confirm Monolog is writing to the configured log path
- Confirm log file is not world-readable
- Test login rate limiting: 6 consecutive failed logins should lock the IP
- Test CSRF: submitting a form without
_csrffield returns 403 - Test role enforcement: viewer account cannot access
/users,/crons/new, etc. - Confirm GitHub Actions
securityworkflow passes (dependency-audit + semgrep green)
- Review log files for unusual patterns (mass failed logins, CSRF failures, 403s)
- Update Composer dependencies and re-run
composer audit(also enforced by CI) - Rotate HMAC secret (update both agent config and web config; restart agent service)
- Review user accounts and remove stale accounts
- Review API keys under
/api-keys: revoke keys that are no longer needed, checklast_used_atfor stale keys, verify scope assignments are still appropriate - Rotate long-lived API keys used by automated scripts
Analysis performed: 2026-03-18; last updated: 2026-06-22 (v4.1.0 – external REST API key authentication) Analyst: Christian Schulz technik@meinetechnikwelt.rocks