View Issue Details

IDProjectCategoryView StatusLast Update
0011697Talerpaivanapublic2026-07-28 17:18
ReporterFlorian Dold Assigned To 
PrioritynormalSeveritymajorReproducibilityhave not tried
Status newResolutionopen 
Summary0011697: paivana paywall response cache is unbounded and keyed on client-controlled headers
Description`load_paywall()` in `src/backend/paivana-httpd_templates.c` memoizes the rendered
paywall page in a per-template linked list of `struct ResponseCacheEntry`. The
lookup keys on two request headers taken **verbatim**:

```c
  lang = MHD_lookup_connection_value (conn, MHD_HEADER_KIND,
                                      MHD_HTTP_HEADER_ACCEPT_LANGUAGE);
  ae = MHD_lookup_connection_value (conn, MHD_HEADER_KIND,
                                      MHD_HTTP_HEADER_ACCEPT_ENCODING);
  for (struct ResponseCacheEntry *pos = t->rce_head; NULL != pos; pos = pos->next)
    if ( (eq (lang, pos->lang)) && (eq (ae, pos->ae)) )
      return MHD_queue_response (conn, pos->http_status, pos->paywall);
```

`eq()` is an exact `strcmp`, so every distinct header string is a distinct key.
On a miss the page is rendered and a new entry is appended
(`GNUNET_CONTAINER_DLL_insert (t->rce_head, t->rce_tail, rce)`). There is no
capacity limit, no expiry and no eviction anywhere: entries are freed only in
`PAIVANA_HTTPD_unload_templates()`, i.e. at shutdown.

Both header values are entirely attacker-controlled and are not normalised before
being used as the key. `TALER_TEMPLATING_build()` does its own language
negotiation internally and falls back to English for anything it does not have a
template for, so the cached *bodies* are in the overwhelming majority identical —
the cache stores many copies of the same page under different keys.

The rendered paywall is not small: the English page is ~48 KB
(`Content-Length: 48498` as served). Each unique key therefore costs roughly that
much, retained for the lifetime of the process.

Two further points:

- The paywall is served on the unauthenticated path — no cookie, no payment, no
  rate limit — so this needs nothing but the ability to make HTTP requests.
- `HEAD` requests reach the same code (after the fix for
  https://bugs.taler.net/n/11640) and are the cheapest way to drive it, since the
  response body is never transferred back to the client. The cache entry is still
  created. The defect itself predates that fix and applies identically to `GET`.
Steps To ReproduceWith paivana running behind a template whose `website_regex` covers some URL:

```shell
for i in $(seq 1 20000); do
  curl -s -o /dev/null --head -H "Accept-Language: x-$i" http://HOST/some-page
done
ps -o rss= -C paivana-httpd
```

RSS grows roughly linearly with the number of distinct header values and never
comes back down. ~20k requests are enough to retain on the order of a gigabyte.
Varying `Accept-Encoding` instead has the same effect.
Additional Information## Suggested fix

Any one of:

- Key the cache on the *negotiated* result — the template language actually
  selected plus whether the body was compressed — rather than on the raw header
  strings. That collapses the key space to the handful of combinations that
  produce genuinely different bytes, and fixes the duplication as a side effect.
- Cap the list (LRU, or simply stop inserting past N entries and render
  uncached beyond that).
- Drop the cache; `Cache-Control: public, max-age=300` is already set on the
  response, so intermediaries and browsers absorb most repeat traffic anyway.

The first option looks best: it is the smallest change that makes the cache both
bounded and actually effective.
TagsNo tags attached.

Activities

There are no notes attached to this issue.

Issue History

Date Modified Username Field Change
2026-07-28 17:18 Florian Dold New Issue