View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011697 | Taler | paivana | public | 2026-07-28 17:18 | 2026-07-28 17:18 |
| Reporter | Florian Dold | Assigned To | |||
| Priority | normal | Severity | major | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0011697: 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 Reproduce | With 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. | ||||
| Tags | No tags attached. | ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-07-28 17:18 | Florian Dold | New Issue |