📑 I have found these related issues/pull requests
I searched open and closed issues for "304", "Content-Length", "apicache" and
"ERR_HTTP2_PROTOCOL_ERROR" and found no existing report for this.
The same code path exists upstream in kwhitley/apicache (src/apicache.js:282), so
this is inherited rather than introduced by the vendored modifications here.
🛡️ Security Policy
📝 Description
On a cache hit, Uptime Kuma emits a 304 response that carries the Content-Length and
Content-Type of the cached 200 body, while correctly sending no body. The result is a
bodyless 304 advertising e.g. Content-Length: 7133.
RFC 9110 §15.4.5 says a 304 SHOULD NOT include representation metadata beyond a small
allowed set. Intermediaries and clients react to the contradiction in incompatible
ways, so the user-visible effect depends entirely on what sits in front of Uptime Kuma.
Root cause: sendCachedResponse() in the vendored apicache module replays the
complete stored header set of the cached 200 response when writing the 304.
This is not a reverse-proxy misconfiguration. The malformed header is emitted by
Uptime Kuma itself and is directly observable with curl against port 3001 with no
proxy in the path.
Scope: sendCachedResponse() serves every cache()-wrapped route, so /status/:slug,
/api/status-page/* and the manifest (cached 1440 minutes) are all affected.
👟 Reproduction steps
The bug only triggers on a cache HIT. A cold cache takes a different path and produces
a correctly formed response, so the cache must be warmed first.
A) Observe the malformed header (no proxy needed)
- Create a published status page with slug
public.
- Warm the cache and capture the ETag:
U=http://127.0.0.1:3001/status/public
ET=$(curl -s -D - -o /dev/null "$U" | awk 'BEGIN{IGNORECASE=1}/^etag:/{print $2}' | tr -d '\r')
echo "$ET"
- Within the 5 minute TTL, repeat as a conditional request:
curl -s -D - -o /dev/null -H "If-None-Match: $ET" "$U"
- Observe
content-length (and content-type) on the 304.
B) Observe the resulting HTTP/2 failure
Behind an intermediary that rejects the contradiction, the conditional request fails
at the protocol level. Deterministic, 3/3 rounds, no browser involved:
U=https://uptimekuma-demo.srv.camp/status/public
ET=$(curl -s -D - -o /dev/null "$U" | awk 'BEGIN{IGNORECASE=1}/^etag:/{print $2}' | tr -d '\r')
for i in 1 2 3; do
A=$(curl -s -o /dev/null -w '%{http_code}' "$U" 2>&1 || echo "STREAM-ERR")
B=$(curl -s -o /dev/null -w '%{http_code}' -H "If-None-Match: $ET" "$U" 2>&1 || echo "STREAM-ERR")
echo "round $i: without=$A with=$B"
done
Result:
round 1: without=200 with=000STREAM-ERR
round 2: without=200 with=000STREAM-ERR
round 3: without=200 with=000STREAM-ERR
The single If-None-Match request header is the only variable.
👀 Expected behavior
The 304 response should not carry representation metadata for a body it does not
send. Specifically Content-Length and Content-Type should be absent, matching
what Express does on its own 304 path and what nginx produces.
The status page should load in Chrome on a normal reload over HTTP/2.
😓 Actual Behavior
- The malformed 304, captured directly (this instance's proxy passes it through):
HTTP/2 304
content-type: text/html; charset=utf-8
etag: W/"1bdd-vbXaWS2ijGudbn0w98VFb8hMhO4"
content-length: 7133
referrer-policy: same-origin
strict-transport-security: max-age=31536000; preload
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
Note 0x1bdd = 7133: the advertised length equals the length encoded in Express's
weak ETag for the cached body, confirming it is the cached representation's size.
Identical over HTTP/1.1 and HTTP/2, which confirms the header originates in the
application rather than in protocol handling.
- The same request against another instance, direct against Uptime Kuma on port 3001,
no proxy in the path:
HTTP/1.1 304 Not Modified
x-frame-options: SAMEORIGIN
content-type: text/html; charset=utf-8
content-length: 2161
etag: W/"871-cA4FVoEHQI6C0QcHzfLQQCelin4"
Date: ...
Connection: keep-alive
Keep-Alive: timeout=5
Header casing is a useful tell here: Date, Connection and Keep-Alive use Node's
canonical casing, while the lowercase content-type, content-length and etag come
from the replayed cache object — i.e. they were not produced by Express's normal 304
handling, which would have removed the content headers.
-
Two environments with different reactions to the same application response:
| Front end |
Behaviour on the malformed 304 |
| Traefik 3.6 |
tolerates it; the 304 with Content-Length reaches the client |
|
unchanged and neither curl nor Chrome complains |
| Traefik 3.7 |
rejects it: `curl: (92) HTTP/2 stream 1 was not closed |
|
cleanly: INTERNAL_ERROR (err 2)`, deterministic 3/3 |
Traefik performs live Content-Length validation, comparing the actual byte count
against the declared header as data is forwarded. A 304 declaring N bytes while
sending zero looks exactly like a truncated stream, so the connection is torn down.
That behaviour is defensible on Traefik's side — the malformed response is the
application's.
I could not find a documented change between 3.6 and 3.7 that explains the
difference in strictness; the version correlation is empirical.
In the Chrome case the failure mode is specific: a normal reload (F5) fails every
time, a hard reload (Ctrl+Shift+R) always works, because a hard reload sends no
conditional header and therefore receives a well-formed 200.
Because some intermediaries tolerate it, this bug is invisible in many deployments
and reproduces reliably in others.
Caveat on evidence: on the Traefik 3.7 instance the 304 headers are not directly
observable, because the stream is reset before headers are delivered. The malformed
header there is inferred from the identical application code path; it is directly
observed on the two instances in (1) and (2).
🐻 Uptime-Kuma Version
2.4.0
💻 Operating System and Arch
Official Docker Image on Debian 13
🌐 Browser
Google Chrome, Version 150.0.7871.128 (Official Build) (64-bit)
🖥️ Deployment Environment
- Runtime Environment:
- Docker: Version
29.6.2 (Build Thu Jul 16 16:12:34 2026)
- Docker Compose: Version
v5.3.1
- Database:
- Database Storage:
- Filesystem:
- Storage Medium: NVMe
- Uptime Kuma Setup:
📝 Relevant log output
No error appears in the Uptime Kuma log — the application considers the response
successfully written, and a pass-through proxy logs a clean 304 in ~2 ms. The failure
is only visible client-side, which is what made this hard to track down.
📑 I have found these related issues/pull requests
I searched open and closed issues for "304", "Content-Length", "apicache" and
"ERR_HTTP2_PROTOCOL_ERROR" and found no existing report for this.
The same code path exists upstream in kwhitley/apicache (src/apicache.js:282), so
this is inherited rather than introduced by the vendored modifications here.
🛡️ Security Policy
📝 Description
On a cache hit, Uptime Kuma emits a 304 response that carries the Content-Length and
Content-Type of the cached 200 body, while correctly sending no body. The result is a
bodyless 304 advertising e.g.
Content-Length: 7133.RFC 9110 §15.4.5 says a 304 SHOULD NOT include representation metadata beyond a small
allowed set. Intermediaries and clients react to the contradiction in incompatible
ways, so the user-visible effect depends entirely on what sits in front of Uptime Kuma.
Root cause:
sendCachedResponse()in the vendored apicache module replays thecomplete stored header set of the cached 200 response when writing the 304.
This is not a reverse-proxy misconfiguration. The malformed header is emitted by
Uptime Kuma itself and is directly observable with curl against port 3001 with no
proxy in the path.
Scope:
sendCachedResponse()serves everycache()-wrapped route, so/status/:slug,/api/status-page/*and the manifest (cached 1440 minutes) are all affected.👟 Reproduction steps
The bug only triggers on a cache HIT. A cold cache takes a different path and produces
a correctly formed response, so the cache must be warmed first.
A) Observe the malformed header (no proxy needed)
public.content-length(andcontent-type) on the 304.B) Observe the resulting HTTP/2 failure
Behind an intermediary that rejects the contradiction, the conditional request fails
at the protocol level. Deterministic, 3/3 rounds, no browser involved:
Result:
The single
If-None-Matchrequest header is the only variable.👀 Expected behavior
The 304 response should not carry representation metadata for a body it does not
send. Specifically
Content-LengthandContent-Typeshould be absent, matchingwhat Express does on its own 304 path and what nginx produces.
The status page should load in Chrome on a normal reload over HTTP/2.
😓 Actual Behavior
HTTP/2 304
content-type: text/html; charset=utf-8
etag: W/"1bdd-vbXaWS2ijGudbn0w98VFb8hMhO4"
content-length: 7133
referrer-policy: same-origin
strict-transport-security: max-age=31536000; preload
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
Note
0x1bdd = 7133: the advertised length equals the length encoded in Express'sweak ETag for the cached body, confirming it is the cached representation's size.
Identical over HTTP/1.1 and HTTP/2, which confirms the header originates in the
application rather than in protocol handling.
no proxy in the path:
HTTP/1.1 304 Not Modified
x-frame-options: SAMEORIGIN
content-type: text/html; charset=utf-8
content-length: 2161
etag: W/"871-cA4FVoEHQI6C0QcHzfLQQCelin4"
Date: ...
Connection: keep-alive
Keep-Alive: timeout=5
Header casing is a useful tell here:
Date,ConnectionandKeep-Aliveuse Node'scanonical casing, while the lowercase
content-type,content-lengthandetagcomefrom the replayed cache object — i.e. they were not produced by Express's normal 304
handling, which would have removed the content headers.
Two environments with different reactions to the same application response:
Traefik performs live Content-Length validation, comparing the actual byte count
against the declared header as data is forwarded. A 304 declaring N bytes while
sending zero looks exactly like a truncated stream, so the connection is torn down.
That behaviour is defensible on Traefik's side — the malformed response is the
application's.
I could not find a documented change between 3.6 and 3.7 that explains the
difference in strictness; the version correlation is empirical.
In the Chrome case the failure mode is specific: a normal reload (F5) fails every
time, a hard reload (Ctrl+Shift+R) always works, because a hard reload sends no
conditional header and therefore receives a well-formed 200.
Because some intermediaries tolerate it, this bug is invisible in many deployments
and reproduces reliably in others.
Caveat on evidence: on the Traefik 3.7 instance the 304 headers are not directly
observable, because the stream is reset before headers are delivered. The malformed
header there is inferred from the identical application code path; it is directly
observed on the two instances in (1) and (2).
🐻 Uptime-Kuma Version
2.4.0
💻 Operating System and Arch
Official Docker Image on Debian 13
🌐 Browser
Google Chrome, Version 150.0.7871.128 (Official Build) (64-bit)
🖥️ Deployment Environment
29.6.2(BuildThu Jul 16 16:12:34 2026)v5.3.15📝 Relevant log output
No error appears in the Uptime Kuma log — the application considers the response
successfully written, and a pass-through proxy logs a clean 304 in ~2 ms. The failure
is only visible client-side, which is what made this hard to track down.