Skip to content

Security: crazy-goat/workerman-bundle

Security

docs/security.md

Security

Header Injection Protection

The RequestConverter applies security hardening when propagating HTTP headers from Workerman to Symfony:

Cookie Header (RFC 6265)

When multiple Cookie header lines are present in the request, values are joined with ; as required by RFC 6265, rather than the standard HTTP , separator. This prevents cookie smuggling where a , byte in a cookie value could be misinterpreted as a separator between cookies.

Before (vulnerable):

Cookie: session=abc123
Cookie: token=xyz789
→ HTTP_COOKIE: "session=abc123, token=xyz789"
→ Cookies parsed as one cookie: session=abc123, token=xyz789

After (hardened):

Cookie: session=abc123
Cookie: token=xyz789
→ HTTP_COOKIE: "session=abc123; token=xyz789"
→ Cookies correctly parsed as two cookies: session=abc123, token=xyz789

Duplicate Sensitive Headers

Duplicate Host, Content-Length, and Authorization headers are suspicious and may indicate request smuggling or header injection attacks. Only the first value of each is propagated to Symfony; subsequent values are silently discarded.

  • Host: Prevents Host-header poisoning attacks
  • Content-Length: Prevents request smuggling via conflicting Content-Length values
  • Authorization: Prevents authorization header injection

Control Character Rejection

Header values containing control characters (\x00-\x08, \x0B, \x0C, \x0E-\x1F, \x7F) are rejected with an \InvalidArgumentException. This prevents:

  • HTTP response splitting via CR/LF injection in header values
  • Log forging via control characters in custom headers
  • Protocol-level attacks through malformed header values

Request URI and Method Validation

The RequestConverter also validates:

  • URI: Control characters in the request URI are rejected (defense against log forging and URI-based access bypass)
  • Method: Only uppercase ASCII letters are allowed (stricter than RFC 7230 to minimise routing bypass attacks), with a maximum length of 32 characters

Middleware Header Re-Injection (Trusted-Proxy Bypass)

The bundle exposes Request::setHeader() (and its deprecated alias withHeader()) so middleware can mutate request headers in place — for example, to attach authentication tokens, override routing hints, or normalise client-supplied values. These methods are intentionally public to every middleware in the pipeline.

The risk

Trust-sensitive headers — X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Ssl, and the standardised Forwarded header — communicate client-supplied network information that downstream code uses to reconstruct the original request origin. Symfony's Request::setTrustedProxies() / setTrustedHosts() filtering exists precisely to prevent untrusted clients from spoofing these values.

Because setHeader() mutates the request after the bundle's own header sanitization has run, any middleware that re-injects these headers from untrusted input re-creates the trusted-proxy bypass class of bugs the bundle works to avoid. The trust boundary is the middleware contract: callers must understand that these methods bypass any earlier sanitization.

Recommended pattern

  • Run trusted-proxy filtering last. If your application relies on Symfony's setTrustedProxies() / setTrustedHosts(), ensure that filtering runs after any middleware that calls setHeader() / withHeader(). The bundle does not enforce an ordering — middleware authors are responsible.
  • Scope-limit forwarding-header writes. If a middleware legitimately needs to set X-Forwarded-* (e.g. a load-balancer-aware middleware that knows the proxy topology), restrict that capability to a small, audited set of services rather than exposing it to arbitrary middleware.
  • Treat middleware-supplied values as untrusted. Even when a middleware is well-behaved, downstream code should treat any header value as attacker-controllable unless the middleware contract explicitly guarantees otherwise.
  • Prefer Symfony's Request::setTrustedProxies() over re-injecting X-Forwarded-* from middleware. Symfony's mechanism is the canonical way to declare which upstream IPs are trusted proxies and which forwarded headers to honour.

When this matters

  • Reverse-proxy deployments where the application sits behind nginx, HAProxy, or a cloud load balancer and uses X-Forwarded-* to reconstruct the client IP / scheme / host.
  • Multi-tenant middleware pipelines where third-party middleware is loaded dynamically and may not be fully audited.
  • Authentication and rate-limiting middleware that branches on X-Forwarded-For to identify clients — a spoofed value here can bypass IP-based rate limits or impersonate other tenants.

Trusted Host Enforcement

Host-header poisoning is a class of attack where an attacker controls the Host header sent to the server, potentially affecting password-reset links, cache keys, and routing decisions made by the application.

By default, all Host header values from incoming requests are accepted. To restrict which hostnames your application responds to, configure trusted_hosts in your Workerman configuration:

workerman:
    trusted_hosts:
        - '^example\.com$'
        - '^api\.example\.com$'

Each entry is a regular expression pattern (without delimiters). Symfony adds the delimiters automatically. A request whose Host header does not match any pattern will be rejected with a SuspiciousOperationException, resulting in a 400 response.

Interaction with Symfony's framework.trusted_hosts

If you also configure framework.trusted_hosts in Symfony, note that:

  • workerman.trusted_hosts is enforced inside the Workerman worker process, before the Symfony kernel handles the request.
  • If both are configured, the Workerman-level enforcement is sufficient — the Symfony-level setting is redundant but harmless.
  • If you use PHP-FPM alongside Workerman workers, configure both independently.

When this matters

Configure trusted_hosts when your application generates absolute URLs based on the incoming Host header (e.g., password-reset emails, webhook callbacks, OAuth redirects). Without it, an attacker can craft a request with a spoofed Host header and trick the application into generating URLs pointing to an attacker-controlled domain.

Static Files Protection

When serve_files is enabled on a server, StaticFilesMiddleware serves files from the configured root directory. This middleware applies security hardening to prevent accidental exposure of sensitive files:

Built-in Denylist

The following are always blocked (requests return 404):

  • Dotfiles and dot-directories: Any path component starting with . is rejected (e.g., .env, .git/HEAD, .htaccess, .hidden/secret.txt).
  • Executable file extensions: .php, .phar, and .phtml files are never served.
  • Well-known leak files: composer.json, composer.lock, and package.json are blocked.
  • Server configuration files: .htaccess and .htpasswd are blocked.

Extension Allowlist

To restrict which file types are served, configure an explicit extension allowlist:

workerman:
    servers:
        - name: 'Web'
          listen: 'http://0.0.0.0:80'
          serve_files: true
          root_dir: '%kernel.project_dir%/public'
          static_files:
              allowed_extensions:
                  - 'css'
                  - 'js'
                  - 'png'
                  - 'jpg'
                  - 'jpeg'
                  - 'gif'
                  - 'webp'
                  - 'svg'
                  - 'woff'
                  - 'woff2'
                  - 'ico'
                  - 'html'
                  - 'json'
                  - 'txt'

When allowed_extensions is set, only files with one of the listed extensions are served — all others return 404. The denylist (dotfiles, .php, etc.) takes precedence and is always enforced regardless of the allowlist setting.

Symlink Protection

By default, StaticFilesMiddleware refuses to serve files that are accessed through a symlink under the static root directory (follow_symlinks: false). This prevents an attacker or a compromised tool from creating a symlink inside the public directory to expose files outside the intended root.

To restore the previous behaviour and allow symlinks to be followed, set follow_symlinks: true:

workerman:
    servers:
        - name: 'Web'
          listen: 'http://0.0.0.0:80'
          serve_files: true
          root_dir: '%kernel.project_dir%/public'
          static_files:
              follow_symlinks: true

When follow_symlinks is false (default), any path component inside the root directory that is a symlink will cause the request to be treated as a non-existent file, passing control to the next middleware.

Security Considerations

  • Keep root_dir isolated: Point root_dir to a dedicated public directory (e.g., %kernel.project_dir%/public). Never set it to the project root or a directory containing .env, source code, or VCS metadata.
  • Use the allowlist: Configure allowed_extensions to only permit the file types your application actually serves as static assets.
  • Disable symlinks: Keep follow_symlinks: false (default) to prevent symlink-based file disclosure unless your application explicitly requires symlinks inside the public directory.
  • 404 for blocked files: Denied files always return a 404 response (identical to non-existent files). This prevents attackers from probing whether a blocked file exists.

Connection Timeouts (Slowloris Protection)

The HTTP server exposes configurable timeouts to protect against slowloris-style denial-of-service attacks, where a small number of clients exhaust worker concurrency by sending data very slowly or keeping connections idle indefinitely.

connection_timeout

The maximum time (in seconds) to wait for a complete request (headers + body) on a newly established connection. If the client does not send the complete request within this window, the connection is closed. This prevents slow-read attacks where an attacker sends headers or body data byte-by-byte.

Default: 120 seconds.

keepalive_timeout

The maximum idle time (in seconds) to keep a keep-alive connection open after the previous request has been fully processed. If no new request arrives within this window, the connection is closed. This prevents idle connections from consuming worker capacity indefinitely.

Default: 30 seconds.

body_size_cap (per-server)

In addition to the global max_package_size, each server can be configured with a per-server body_size_cap that overrides the global limit for that specific server. This allows tightening limits on public-facing endpoints while keeping larger limits for internal endpoints.

workerman:
    max_package_size: 10485760  # 10 MB global default

    servers:
        - name: 'Api'
          listen: 'http://0.0.0.0:80'
          body_size_cap: 1048576  # 1 MB for this server (overrides global)

        - name: 'Upload'
          listen: 'http://0.0.0.0:8080'
          body_size_cap: 52428800  # 50 MB for file uploads

Example Configuration

workerman:
    connection_timeout: 60
    keepalive_timeout: 15

    servers:
        - name: 'Web'
          listen: 'http://0.0.0.0:80'

Security Considerations

  • connection_timeout protects against slowloris: Without this timeout, an attacker can open many connections and send data extremely slowly, holding each worker process indefinitely. Setting this to a reasonable value (e.g., 30-120 seconds) limits the window of exposure.
  • keepalive_timeout limits idle connections: Long-lived idle keep-alive connections reduce the number of available worker slots. A short keepalive timeout (e.g., 15-30 seconds) frees capacity quickly.
  • body_size_cap for defense-in-depth: Even with a global max_package_size, setting tighter per-server limits on endpoints that expect small payloads (e.g., API endpoints) provides an additional layer of protection against oversized payloads.
  • Timers are per-connection: Each connection's timers are independent. A slowloris attack on one connection does not affect timing on others.

SSL Certificate and Key Validation

When configuring HTTPS or WSS servers, the local_cert and local_pk options specify the paths to the SSL certificate and private key files. These paths are validated before being passed to the TLS stream context:

Regular File Check

Both paths must point to regular files. Configuration pointing to:

  • Directories are rejected with a clear error message.
  • FIFO/named pipes are rejected.
  • Device files (e.g., /dev/zero, /dev/random) are rejected.

This prevents an attacker or misconfiguration from causing the TLS stack to read unbounded bytes from a device file or hang on a FIFO.

Symlink Protection

Symlinked certificate and key paths are rejected by default. This prevents:

  • Symlink-based file disclosure: An attacker who controls a compromised tool or deployment process cannot create a symlink pointing the cert/key to a different file.
  • Unintended key material exposure: A symlink swapped at runtime would load different credentials than expected.

Error Messages

  • SSL certificate path must not be a symlink: <path>
  • SSL private key path must not be a symlink: <path>
  • SSL certificate path must be a regular file: <path>
  • SSL private key path must be a regular file: <path>

These checks are applied in addition to the existing is_readable() validation, which catches non-existent files and permission issues.

SFX Download Protection (Zip-Slip)

The SfxDownloader downloads and extracts phpmicro.sfx from upstream HTTPS mirrors. Before extracting a downloaded ZIP archive, each entry name is validated against path traversal attacks (zip-slip):

  • Backslashes: Entry names containing backslashes (\) are rejected.
  • Absolute paths: Entry names starting with / or a Windows drive letter (C:\) are rejected.
  • Path traversal: Entry names containing .. segments after normalization are rejected.
  • Destination containment: Each entry is checked to ensure it resolves to a path inside the destination directory.

If any entry fails validation, the build aborts with a \RuntimeException.

SFX Download Protection (Redirect Scheme)

When downloading phpmicro.sfx with --insecure (or build.sfx.allow_insecure: true), TLS peer verification is disabled. To prevent an on-path attacker from downgrading the download from HTTPS to plain HTTP via a redirect:

  • Automatic redirect following is disabled in the stream context
  • Each redirect response is inspected manually
  • Cross-scheme redirects (HTTPS → HTTP) are blocked with a hard error
  • Redirects within the same scheme (HTTPS → HTTPS) are still allowed (up to 5 hops)

This defense is always active when allow_insecure is enabled, regardless of whether a checksum is configured.

Status File TOCTOU Protection

ServerManager reads and removes status and connections files written by the Workerman master process (SIGIOT for status, SIGIO for connections). The read-then-unlink sequence is a classic TOCTOU (time-of-check, time-of-use) vulnerability: a local attacker (or another process) on the same host can swap the file between the read and the unlink, redirecting the unlink at an arbitrary path.

Defence

The consumeFile() method uses an atomic rename-before-read pattern:

  1. The file at $path is atomically renamed to a unique temporary path within the same directory (rename() is atomic on the same filesystem).
  2. After the rename, $path no longer exists — a symlink swap at the original path cannot redirect the subsequent read or unlink.
  3. The content is read from the renamed temp path.
  4. The renamed temp path is unlinked. Since this path was created by our own rename(), it is not subject to symlink swaps.
  5. Unlink failures are surfaced to the PSR-3 logger instead of being silently suppressed with @.

When this matters

  • Multi-user hosts: An attacker with write access to the runtime directory (or a compromised sibling process) cannot redirect the unlink() to a different file.
  • Shared runtime directories: If the runtime directory is shared between multiple services or users, the TOCTOU window is broader.

Verification

  • The testConsumeFileRemovesOriginalInodeAfterRename test verifies that after consumeFile(), a new file at the original path has a different inode.
  • The testConsumeFileCreatesNoOrphanedTempFiles test verifies cleanup.
  • The testConsumeFileReturnsContentAndRemovesTempFile test verifies normal operation.

Runtime Directory Permissions

Runtime directories (PID file, log files, stdout files) are created with mode 0700 (owner-only access). This prevents other users on multi-user systems from reading process-control artifacts such as PID files and status files.

When this matters

  • Multi-user hosts: Shared CI runners, containers with multiple service accounts, or any environment where the process user should not be able to read another user's runtime files.
  • PID file protection: An attacker who can read the PID file can signal the workerman master process.
  • Status file protection: Status files contain internal process state that should not be visible to other users.

Behaviour

  • Runtime directories are created automatically with 0700 permissions when they do not exist.
  • If the directory already exists, its permissions are not modified — ensure it was created with appropriate restrictive permissions if it was pre-created.
  • To verify permissions on a running system:
    stat -c '%a %n' var/run/ var/log/

Config Cache File Protection

The ConfigLoader reads the application configuration from a cached PHP file generated during cache warm-up ({cacheDir}/workerman/config.cache.php). This file is loaded via require at boot time.

Trust Requirement

The configuration cache file is a PHP file that gets executed. Any attacker who can write to the cache directory can achieve arbitrary code execution by modifying this file. The bundle relies on the standard Symfony assumption that the cache directory is not writable by untrusted users.

Permission Validation

To mitigate misconfiguration, the bundle validates the cache file permissions before loading it:

  • World-writable check: If the cache file is world-writable (permissions include o+w), loading is refused with a clear error message.
  • Scope: The check covers POSIX file permissions only. It does not cover ACLs, extended attributes, or filesystems that do not support POSIX permissions.

When this matters

  • Multi-tenant environments: Shared hosting or CI runners where the cache directory might be accessible to other users.
  • Containerised deployments: Containers with overly permissive umask settings (e.g., umask 0000) can produce world-writable cache files.
  • Development setups: Developers running with umask 0000 or cache directories created with 0777 permissions.

Remediation

Ensure the cache directory has restrictive permissions:

chmod 0700 var/cache/

Or, if the web server and CLI users differ, use a shared group:

chmod 0750 var/cache/
chgrp <webserver-group> var/cache/

SFX Checksum Requirement

The build fails if no SHA-256 checksum is configured, unless --unsafe-no-checksum is explicitly passed. This ensures supply-chain integrity by default:

  • --sfx-checksum=HASH or config build.sfx.sha256 → checksum verified after download
  • --unsafe-no-checksum → no verification (not recommended)
  • Neither → build aborts with an error

There aren't any published security advisories