https://bz.apache.org/bugzilla/show_bug.cgi?id=70028
Bug ID: 70028
Summary: mod_cache trunk regression: comma-separated
Cache-Control directives are not parsed correctly,
allowing private/no-store responses to be cached
Product: Apache httpd-2
Version: 2.5-HEAD
Hardware: PC
OS: All
Status: NEW
Severity: major
Priority: P2
Component: mod_cache
Assignee: [email protected]
Reporter: [email protected]
Target Milestone: ---
This was originally reported to [email protected]. I was asked to open a
public Bugzilla issue because the impact appears to be trunk-only.
Summary:
While testing Apache httpd trunk / 2.5.1-dev, I found a regression in the
Cache-Control parser used by mod_cache.
A response containing multiple comma-separated Cache-Control directives such
as:
Cache-Control: private, no-store, max-age=0
is not parsed correctly by trunk in my test build. As a result, mod_cache does
not set the expected cache_control_t flags:
private = 1
no_store = 1
max_age = 1
and the response can be cached even when the configuration explicitly uses the
safe/default behaviour:
CacheStorePrivate Off
CacheStoreNoStore Off
This allows an authenticated private response to be stored by the shared
reverse proxy cache and later served to an anonymous request for the same URL.
This does not reproduce on the 2.4.x branch in my test: 2.4.x correctly rejects
the response with:
Reason: Cache-Control: no-store present
Affected version tested:
Apache/2.5.1-dev (Unix)
trunk build
Unaffected comparison:
Apache/2.4.68-dev (Unix)
2.4.x branch
Relevant configuration:
CacheRoot ".../cache"
CacheEnable disk "/proxy/"
CacheQuickHandler On
CacheHeader on
CacheLock on
CacheStorePrivate Off
CacheStoreNoStore Off
CacheMaxFileSize 10000000
CacheMinFileSize 1
Reproduction:
1. Run Apache httpd trunk / 2.5.1-dev as a reverse proxy with mod_cache and
mod_cache_disk enabled.
2. Configure caching for a proxied path, for example:
CacheEnable disk "/proxy/"
CacheStorePrivate Off
CacheStoreNoStore Off
CacheHeader on
3. Configure the backend to return a user/session-dependent response with:
Cache-Control: private, no-store, max-age=0
Vary: Host
Last-Modified: Thu, 01 Jan 2026 00:00:00 GMT
ETag: "CACHE-TEST"
4. First request the URL with an authenticated/session cookie:
GET /proxy/admin/dashboard HTTP/1.1
Host: public.localhost
Cookie: session=VICTIM_ADMIN_SESSION
The backend returns an authenticated/private response.
5. Then request the same URL without any Cookie header.
Expected result:
The first response must not be stored because it contains:
Cache-Control: private, no-store, max-age=0
The anonymous request should go to the backend and receive the anonymous
response.
Actual result on trunk:
The private response is cached and later served to the anonymous request.
Evidence from my test:
Authenticated request:
HTTP/1.1 200 OK
Cache-Control: private, no-store, max-age=0
Vary: Host
X-Cache: MISS from public.localhost
AUTHENTICATED_ADMIN_VIEW
Session marker: session=VICTIM_ADMIN_SESSION
CSRF token: CSRF_TOKEN_...
API key preview: sk_live_ADMIN_SECRET_1777
Anonymous request to the same URL:
HTTP/1.1 200 OK
Cache-Control: private, no-store, max-age=0
Vary: Host
Age: 0
X-Cache: HIT from public.localhost
AUTHENTICATED_ADMIN_VIEW
Session marker: session=VICTIM_ADMIN_SESSION
CSRF token: CSRF_TOKEN_...
API key preview: sk_live_ADMIN_SECRET_1777
Relevant cache log:
AH00769: cache: Caching url
http://public.localhost:80/proxy/admin/dashboard? for request
/proxy/admin/dashboard
AH00737: commit_entity: Headers and body for URL
http://public.localhost:80/proxy/admin/dashboard? cached.
AH00764: cache: serving /proxy/admin/dashboard
Root cause analysis:
The regression appears to be in modules/cache/cache_util.c, in the trunk
implementation of cache_strqtok() / ap_cache_control().
In the trunk parser, the IN_TOKEN state does not stop when it reaches a token
separator such as comma or whitespace. Therefore a header such as:
private, no-store, max-age=0
can be treated as a single token rather than separate directives. This causes
the comparisons for "private", "no-store", and "max-age" to fail.
In 2.4.x, the older parser correctly splits the Cache-Control header and sets
the expected flags.
I locally tested a minimal fix by stopping the IN_TOKEN state when
TEST_CHAR(*str, T_HTTP_TOKEN_STOP) is reached, before falling through to
argument parsing. After that change, trunk correctly parses:
Cache-Control: private, no-store, max-age=0
and mod_cache refuses to store the response:
DEBUG_CACHE_DECISION_POST: no_store=1 private=1 max_age=1 max_age_value=0
AH00768: cache: ... not cached ... Reason: Cache-Control: no-store present
Suggested fix direction:
In cache_strqtok(), while in IN_TOKEN state, stop token parsing when a token
separator / HTTP token stop character is encountered, so that comma-separated
Cache-Control directives are parsed independently.
Example local patch direction:
case IN_TOKEN:
if (*str == '=') {
state = IN_BETWEEN;
*wpos++ = '\0';
if (arg) *arg = wpos;
continue;
}
if (TEST_CHAR(*str, T_HTTP_TOKEN_STOP)) {
goto end;
}
break;
Security impact:
This is trunk-only based on my testing, so I understand this may not receive a
CVE. However, if this regression reached a stable release, a shared reverse
proxy cache could turn authenticated private responses into shared cache
objects retrievable by anonymous users.
This could expose admin dashboards, account pages, CSRF tokens, API key
previews, billing pages, /api/me responses, or other session-dependent content
whenever the backend relies on Cache-Control: private/no-store to prevent
shared caching.
I can provide the complete PoC lab files and command output if useful.
--
You are receiving this mail because:
You are the assignee for the bug.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]