12 min read
Medium
By

FrankenPHP 1.12.4 closes underscore-header spoofing — and bundles the security patches from Caddy 2.11.4 and Mercure 0.24.2

4 June 2026. FrankenPHP 1.12.4 is a hardening and stability release that, per the project, every operator should install. The headline is defense-in-depth against underscore-header spoofing; on top of it come the bundled security patches from Caddy 2.11.4 and Mercure 0.24.2 plus several crash and data-race fixes in worker mode. Not an actively exploited 0-day, but a clear upgrade with pressure to act within the week.

TL;DR — 90 seconds

Affected?

All FrankenPHP installations before 1.12.4 — standalone binary, official Docker image, worker mode, Symfony runtime. Aggravated when the app or an upstream proxy trusts headers with underscores (e.g. X_Forwarded_For, auth headers) or when Mercure (real-time/SSE) is in use.

Risk?

Header spoofing (trust/auth-bypass class via the CGI dash-to-underscore collision), SSE field injection (CWE-93) and metadata disclosure in the Mercure module, a TLS client-auth bug, plus crashes/data races in worker mode (DoS/stability class).

Immediate action?

Update to FrankenPHP 1.12.4 (re-pull binary/image, restart workers). The bundled Caddy 2.11.4 now ignores header fields with underscores at the server layer.

Recommendation?

SMEs and enterprise: install in the running maintenance window; prioritise with Mercure use or proxy chains carrying underscore headers. First audit app code that reads $_SERVER['HTTP_*'] for auth/trust decisions.

Criticality?

medium (references the hero badge — hardening, no active exploit, but „every user should upgrade“).

What is the problem?

We take the one thing the release title rightly foregrounds: the underscore-header collision. The CGI convention maps dashes in HTTP header names to underscores and prepends an HTTP_ prefix — the header Foo-Bar becomes the server variable HTTP_FOO_BAR. The problem: a client can also send a header Foo_Bar (with an underscore), and it lands as the same HTTP_FOO_BAR in $_SERVER. An attacker-set underscore header is then indistinguishable in the application from a legitimate dash header.

This is a classic trust collision. Wherever an application or an upstream reverse proxy trusts a particular header — X-Forwarded-For, an auth or tenant header, an internally set „this request came from our gateway“ marker — a client can smuggle in the underscore variant and forge the value. Depending on architecture this ranges from IP spoofing in logs and rate limits to auth bypass when a header serves as proof of identity.

FrankenPHP 1.12.4 pulls the defense to the server layer: the bundled Caddy 2.11.4 now ignores HTTP header fields whose name contains an underscore — the collision never arises in the first place. Anyone using the Go API directly (NewRequestWithContext) now gets the risk documented explicitly. The class was reported by Vincent550102 and patched upstream by dunglas.

The release is more than this single point. It bundles the full security patches from Caddy 2.11.4 (TLS client-auth fix, Windows backslash normalisation in the path matcher, prevention of placeholder re-expansion in injected queries, improved stripHTML, a patch for GHSA-vcc4-2c75-vc9v) and the hardening from Mercure 0.24.2 for the Mercure Caddy module (rejection of SSE field injection via id/type — CWE-93, blocking topic forgery on the reserved /.well-known/mercure, a fix for a Last-Event-ID metadata disclosure, DoS amplification caps). On top come worker-mode fixes: an ext-parallel crash from an incorrectly propagated parent thread index, a stuck close handler from an uncleared in_save_handler, a data race in metrics (mutex switched to an RW mutex) and a headers_sent() misbehaviour under CLI emulation.

Who is affected?

AffectedNot affectedConditions / aggravating
FrankenPHP < 1.12.4 (standalone binary, official dunglas/frankenphp image, static builds)FrankenPHP 1.12.4 and newer after restarting all workers/processesApp or proxy trusts headers whose name can contain underscores (X_Forwarded_For, auth/tenant headers)
Worker-mode deployments (Symfony runtime, Octane-like setups)Plain classic FPM setup without FrankenPHPDirect use of the Go API (net/http path, NewRequestWithContext)
Installations with the Mercure module active (real-time/SSE)FrankenPHP without a Mercure hub and without SSEMercure hub publicly reachable; id/type/Last-Event-ID from an untrusted source
Container/Kubernetes deployments using the FrankenPHP image as a baseImage not rebuilt/re-pulled; old layers with Caddy < 2.11.4 / Mercure < 0.24.2 stay active

In short: practically every FrankenPHP installation is „affected“ in the sense of „should update“. How sharp it is operationally depends on two things — whether header-based trust (proxy chain, auth header) is in play and whether Mercure is running.

Impact

The header collision is not „remote code execution at the push of a button“ but a trust weakness whose severity depends on the architecture. In the harmless case an attacker forges X-Forwarded-For and thereby IP logging, geo logic or rate limits. In the serious case — an app or internal proxy uses a header as an identity or trust marker without stripping it cleanly at the edge — it becomes an auth or authorisation bypass. That is exactly why the project classes it as defense-in-depth and recommends the update for everyone: the server should not pass the ambiguity to the application in the first place.

The Mercure hardening addresses more concrete attacks: SSE field injection (CWE-93) allows the event stream to be tampered with via manipulated id/type fields; the topic forgery on /.well-known/mercure and the Last-Event-ID disclosure affect confidentiality and integrity of the real-time layer; the new DoS caps limit amplification. The worker-mode fixes (crashes, data races) are primarily stability and availability — in a long-running app server a data race in the metrics or a stuck close handler is a real DoS/memory path under load.

An official CVSS rating for the FrankenPHP-specific points is not consistently available; Caddy points to GHSA-vcc4-2c75-vc9v for part of it. Our assessment is therefore pragmatic: medium, because it is broadly relevant and „every user should upgrade“, but without known active exploitation and without a trivial universal RCE.

Mitigation / immediate measures

Operational Decision Block

Path 1 — Docker / container

 

# Re-pull the image and pin the tag (do not blindly trust :latest)
docker pull dunglas/frankenphp:1.12.4

# Pin the base in the Dockerfile and rebuild so Caddy 2.11.4 / Mercure 0.24.2 land in the layers
# FROM dunglas/frankenphp:1.12.4
docker compose build --pull --no-cache app
docker compose up -d

# Verify the running version in the container
docker compose exec app frankenphp version

 

Path 2 — standalone binary / static build

 

# Obtain the new version (release assets at github.com/php/frankenphp/releases/tag/v1.12.4)
# then check the version
frankenphp version

# Restart the app server / workers cleanly so no old process keeps running
systemctl restart frankenphp        # or your own service name
# or in worker mode restart the supervisor / Octane / runtime worker

 

Path 3 — direct Go API use

If you embed FrankenPHP/Caddy programmatically via the Go API and build requests yourself (NewRequestWithContext), read the documentation on the underscore risk added in 1.12.4 (PR #2460) and strip underscore headers explicitly before they enter the $_SERVER/CGI path.

Defense-in-depth at the edge (independent of the update)

Detection / verification

Determine the version

 

# Directly
frankenphp version          # expected: v1.12.4 (or newer)

# In the container
docker compose exec app frankenphp version

# Surface the bundled Caddy version (where available as a caddy build)
caddy version              # target: v2.11.4

 

Audit app code for header trust

 

# Find places that read HTTP headers from $_SERVER for trust/auth decisions
grep -RInE "\$_SERVER\['HTTP_" app/ src/ public/

# Especially check forwarded/auth/tenant markers
grep -RInE "HTTP_X_FORWARDED|HTTP_X_REAL_IP|HTTP_X_.*AUTH|HTTP_X_TENANT" app/ src/

 

Check Mercure exposure

 

# Is a Mercure hub running, and is it reachable from outside?
curl -sS -o /dev/null -w "%{http_code}\n" YOUR-DOMAIN/.well-known/mercure
# If publicly reachable: prioritise the update to 0.24.2 (in FrankenPHP 1.12.4),
# re-check topic authorisation and JWT configuration.

 

Worker-mode stability

After the update, watch metrics/logs in worker mode: no more sporadic crashes from ext-parallel, no stuck close handlers after session save, the metrics endpoint under load without data-race anomalies.

Operator guidance

SMEs / Mittelstand

This is a regular but non-deferrable hardening update. We recommend: move to 1.12.4 in the next maintenance window, restart workers, verify the version. If you run a reverse proxy (nginx, Traefik, cloud LB) in front of FrankenPHP, also check header hygiene at the edge — that is the genuinely durable protection against the spoofing class and applies independently of FrankenPHP.

Enterprise

Plan the change normally, but prioritise it if header-based trust is part of the architecture (an internal proxy sets identity/tenant headers) or if Mercure runs in production. Include the audit of app code for $_SERVER['HTTP_*'] trust paths in the change — the server fix closes the collision but does not replace a clean trust boundary.

Kubernetes / containers

Rebuild the image, do not just restart pods. Old layers with Caddy < 2.11.4 / Mercure < 0.24.2 otherwise stay active. Pin the base-image tag in the pipeline to 1.12.4, run the SBOM/layer scan in CI, then roll out. With several services sharing a base image, trigger the rebuild centrally.

Declarative stacks (NixOS / Talos / Flatcar)

Raise the package/image pin to 1.12.4, roll out declaratively, restart workers through the usual reconcile. The advantage here: the version state is reproducibly verifiable — exactly the auditability that bundled dependencies (Caddy/Mercure inside the FrankenPHP binary) otherwise easily lose.

What we did concretely

We treat FrankenPHP for what it is: an app server that brings Caddy and Mercure with it — i.e. a bundled supply chain in a single binary. For the platforms we operate this means: raised the base-image tag to 1.12.4, rebuilt images with --no-cache --pull so Caddy 2.11.4 and Mercure 0.24.2 actually land in the layers, and cleanly restarted the workers during rollout. Afterwards we verified the version states in the container and double-checked header hygiene at the upstream proxies — forwarded/auth headers are set at the trust boundary, not passed through, and underscore headers are dropped at the edge.

The second part is the app-side audit: we looked for the trust paths that read $_SERVER['HTTP_*'] for identity or rate-limit decisions and documented where a header serves as a trust anchor. The lesson from this release is not the single line of Caddy code but the architectural discipline behind it: a bundled app server moves the supply chain into your image. Anyone who cannot reproducibly prove the version state of the embedded components is patching blind. That is why the Caddy/Mercure version state sits in our SBOM and CI gate, not in someone’s head.

Frequently asked questions about FrankenPHP 1.12.4

Do we have to rebuild the FrankenPHP Docker image, or is a pod restart enough?+

Rebuild. Caddy 2.11.4 and Mercure 0.24.2 are embedded in the FrankenPHP binary/image. A mere restart loads the same old layers. Pin the base tag to 1.12.4, rebuild with docker compose build --pull --no-cache, then roll out and verify with frankenphp version.

Are we affected if our app does not use underscore headers at all?+

Indirectly yes. The attacker sets the underscore header, not you. What matters is whether your app or an upstream proxy trusts a header whose dash variant (e.g. X-Forwarded-For) collides with the underscore variant through the CGI mapping. 1.12.4 drops underscore headers at the server; additionally strip them at the edge.

How do I check whether our Mercure hub benefits from the hardening?+

Check the FrankenPHP version (frankenphp version → v1.12.4) and the reachability of /.well-known/mercure. With 1.12.4, Mercure 0.24.2 is bundled, which closes SSE field injection (CWE-93), topic forgery on /.well-known/mercure and the Last-Event-ID disclosure, and adds DoS caps.

Is FrankenPHP 1.12.4 an actively exploited emergency patch?+

No. It is a hardening and stability release for which the project writes „every user should upgrade“, without any active exploitation being known. That is why we class it as medium: broadly relevant, install within the week, prioritise with proxy trust or Mercure.

What is GHSA-vcc4-2c75-vc9v and does it affect us via FrankenPHP?+

It is a security advisory patched in Caddy 2.11.4 (PR #7785). Since FrankenPHP embeds Caddy, the fix comes with 1.12.4 automatically. If you additionally run Caddy standalone, raise it to 2.11.4 there separately.

Is updating FrankenPHP enough, or do we also need to do something at the reverse proxy?+

The update closes the collision at the server. The durable protection, however, is the trust boundary: set or strip forwarded/auth headers at the edge, never pass them through from the client. The two together are the defense-in-depth the release means.

Conclusion

FrankenPHP 1.12.4 is not a spectacular 0-day patch but solid hardening — and precisely therefore easy to underestimate. The underscore-header collision is an old CGI truth that becomes relevant again in modern app-server setups with proxy chains; the server-side rejection closes the ambiguity but does not replace a clean trust boundary at the edge. More important than the single line is the reminder that a bundled app server carries Caddy and Mercure into your image: anyone who cannot reproducibly prove the version state of these embedded components is patching blind. Install the update, rebuild the image, double-check header hygiene and app trust paths — do not dramatise, but do not leave it lying around either.

Sources

Before the next bundled patch arrives — let’s talk about your app-server supply chain.

We update, harden and verify your FrankenPHP platform against header spoofing and real-time risks.

SBOM inventory of the bundled components (FrankenPHP/Caddy/Mercure), image rebuild with a pinned 1.12.4 tag, worker rollout and version verification — plus the app-side audit of the $_SERVER['HTTP_*'] trust paths together with edge header hygiene.

Platform operations instead of advice-on-paper: we check, mitigate and validate production platforms — from the patch in the maintenance window to PoC validation of the mitigation.

Book an appointment directly

About the author

[Translate to English:] Foto von Kai Ole Hartwig.

Kai Ole Hartwig

Founder · Moselwal Digitalagentur · OnlyOle

Programming since 2002 – self-taught, set up my own business with KO-Web in 2012, now Moselwal. Over 100 projects, with a focus on security, performance, automation and quality.