Skip to content

Cached status page emits 304 with Content-Length of the cached body, causing HTTP/2 stream errors (reproducible with curl) #7635

Description

@tldev-de

📑 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)

  1. Create a published status page with slug public.
  2. 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"
  1. Within the 5 minute TTL, repeat as a conditional request:
curl -s -D - -o /dev/null -H "If-None-Match: $ET" "$U"
  1. 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

  1. 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.

  1. 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.

  1. 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:
    • SQLite: Embedded
  • Database Storage:
    • Filesystem:
      • Linux: ext4
    • Storage Medium: NVMe
  • Uptime Kuma Setup:
    • Number of monitors: 5

📝 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions