Skip to content

Commit 75c954f

Browse files
authored
Merge pull request #8 from bluedynamics/fix/no-cache-error-responses
fix: microcache error responses instead of pinning long-TTL (closes #5)
2 parents a4dcf9e + 944b05a commit 75c954f

5 files changed

Lines changed: 122 additions & 4 deletions

File tree

CHANGES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
## 0.4.3 (unreleased)
44

5+
- Fix: `AuthImagingHandler.finish()` no longer applies the long-TTL
6+
`Cache-Control` override to error responses (4xx/5xx). Previously a
7+
transient Thumbor 400 (e.g. a PIL decompression-bomb rejection) would
8+
inherit `public, max-age=31536000, immutable` and get pinned in
9+
downstream HTTP caches (Varnish, CDN) for a year — a single bad fetch
10+
persistently hid the image on every shard that cached it.
11+
Errors now get a short microcache (`PGTHUMBOR_CACHE_CONTROL_ERROR`,
12+
default `public, max-age=10`) instead: decouples transient errors
13+
from long-term cache poisoning AND lets downstream caches absorb
14+
request floods for broken URLs (cheap DoS amplification defense —
15+
a single bad URL won't fan out to one Thumbor hit per request).
16+
3xx responses are left untouched (Thumbor default).
17+
Fixes [#5](https://github.com/bluedynamics/zodb-pgjsonb-thumborblobloader/issues/5).
18+
519
- Fix: boto3 S3 client is now created with an explicit
620
`max_pool_connections` via `botocore.Config` (default 50, overridable
721
via `PGTHUMBOR_S3_MAX_POOL_CONNECTIONS`). The boto3 default of 10

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ The image is automatically rebuilt weekly when a new Thumbor version appears on
106106
| `PGTHUMBOR_AUTH_CACHE_TTL` | `60` | Auth cache TTL in seconds |
107107
| `PGTHUMBOR_CACHE_CONTROL_AUTHENTICATED` | `private, max-age=86400` | Cache-Control for authenticated images (browser-only, no proxy caching) |
108108
| `PGTHUMBOR_CACHE_CONTROL_PUBLIC` | `""` | Cache-Control for public images (empty = Thumbor default) |
109+
| `PGTHUMBOR_CACHE_CONTROL_ERROR` | `public, max-age=10` | Cache-Control for 4xx/5xx responses — microcache decouples transient errors from long-term cache poisoning and absorbs request floods for broken URLs |
109110

110111
The Plone auth handler (and Cache-Control overrides) is only loaded when `PGTHUMBOR_PLONE_AUTH_URL` is set.
111112

src/zodb_pgjsonb_thumborblobloader/auth_handler.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,28 @@ async def get(self, **kwargs):
7070
await super().get(**kwargs)
7171

7272
def finish(self, *args, **kwargs):
73+
# Only apply the long-TTL Cache-Control override on success.
74+
# Otherwise a transient 4xx/5xx from Thumbor (e.g. a PIL
75+
# decompression-bomb 400) would get pinned in downstream HTTP
76+
# caches for the full max-age window, hiding the image for a year.
77+
#
78+
# Errors get a short "microcache" TTL instead of no-store: it
79+
# decouples transient errors from long-term cache poisoning,
80+
# *and* it absorbs request floods for the same broken URL —
81+
# downstream caches serve the error themselves for the next
82+
# few seconds instead of each request hitting Thumbor. Cheap
83+
# DoS amplification defense on top of the primary fix.
84+
status = self.get_status()
7385
cc = getattr(self, "_cache_control_override", "")
74-
if cc:
75-
self.set_header("Cache-Control", cc)
86+
if 200 <= status < 300:
87+
if cc:
88+
self.set_header("Cache-Control", cc)
89+
elif status >= 400:
90+
error_cc = self.context.config.get(
91+
"PGTHUMBOR_CACHE_CONTROL_ERROR",
92+
"public, max-age=10",
93+
)
94+
self.set_header("Cache-Control", error_cc)
7695
super().finish(*args, **kwargs)
7796

7897
def _extract_content_zoid(self) -> str | None:

tests/test_auth_handler.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ def _make_handler(
228228
path="/hmac/300x200/42/ff/1a",
229229
cc_auth="private, max-age=86400",
230230
cc_public="",
231+
cc_error="public, max-age=10",
232+
status=200,
231233
):
232234
from zodb_pgjsonb_thumborblobloader.auth_handler import _auth_cache
233235
from zodb_pgjsonb_thumborblobloader.auth_handler import AuthImagingHandler
@@ -239,13 +241,17 @@ def _make_handler(
239241
handler.request.path = path
240242
handler.request.headers = {"Cookie": "auth=abc123"}
241243
handler.context = MagicMock()
242-
handler.context.config.get = lambda key, default=None: {
244+
config = {
243245
"PGTHUMBOR_PLONE_AUTH_URL": "http://plone:8080/Plone",
244246
"PGTHUMBOR_AUTH_CACHE_TTL": 60,
245247
"PGTHUMBOR_CACHE_CONTROL_AUTHENTICATED": cc_auth,
246248
"PGTHUMBOR_CACHE_CONTROL_PUBLIC": cc_public,
247-
}.get(key, default)
249+
}
250+
if cc_error is not None:
251+
config["PGTHUMBOR_CACHE_CONTROL_ERROR"] = cc_error
252+
handler.context.config.get = lambda key, default=None: config.get(key, default)
248253
handler._headers = {}
254+
handler.get_status = lambda: status
249255
return handler
250256

251257
def test_authenticated_request_sets_private(self):
@@ -322,6 +328,78 @@ def test_finish_skips_header_when_empty_override(self):
322328

323329
assert "Cache-Control" not in headers_set
324330

331+
def test_finish_error_status_gets_microcache(self):
332+
"""4xx response gets a short microcache, not the long-TTL override."""
333+
handler = self._make_handler(status=400)
334+
handler._cache_control_override = "public, max-age=31536000, immutable"
335+
headers_set = {}
336+
handler.set_header = lambda k, v: headers_set.update({k: v})
337+
338+
with patch.object(
339+
type(handler).__mro__[1], "finish", lambda self, *a, **kw: None
340+
):
341+
handler.finish()
342+
343+
# The long-TTL override MUST NOT leak into the error response.
344+
assert headers_set["Cache-Control"] == "public, max-age=10"
345+
346+
def test_finish_error_without_override_still_gets_microcache(self):
347+
"""Error responses get a microcache even if no override was set."""
348+
handler = self._make_handler(status=404)
349+
# No _cache_control_override attribute set at all
350+
headers_set = {}
351+
handler.set_header = lambda k, v: headers_set.update({k: v})
352+
353+
with patch.object(
354+
type(handler).__mro__[1], "finish", lambda self, *a, **kw: None
355+
):
356+
handler.finish()
357+
358+
assert headers_set["Cache-Control"] == "public, max-age=10"
359+
360+
def test_finish_5xx_also_microcached(self):
361+
"""5xx responses get the same microcache treatment as 4xx."""
362+
handler = self._make_handler(status=503)
363+
handler._cache_control_override = "public, max-age=31536000, immutable"
364+
headers_set = {}
365+
handler.set_header = lambda k, v: headers_set.update({k: v})
366+
367+
with patch.object(
368+
type(handler).__mro__[1], "finish", lambda self, *a, **kw: None
369+
):
370+
handler.finish()
371+
372+
assert headers_set["Cache-Control"] == "public, max-age=10"
373+
374+
def test_finish_error_microcache_configurable(self):
375+
"""PGTHUMBOR_CACHE_CONTROL_ERROR overrides the default microcache."""
376+
handler = self._make_handler(
377+
status=400, cc_error="private, max-age=30, must-revalidate"
378+
)
379+
headers_set = {}
380+
handler.set_header = lambda k, v: headers_set.update({k: v})
381+
382+
with patch.object(
383+
type(handler).__mro__[1], "finish", lambda self, *a, **kw: None
384+
):
385+
handler.finish()
386+
387+
assert headers_set["Cache-Control"] == "private, max-age=30, must-revalidate"
388+
389+
def test_finish_3xx_leaves_cache_control_alone(self):
390+
"""Redirects: no long-TTL override, no microcache — Thumbor default."""
391+
handler = self._make_handler(status=304)
392+
handler._cache_control_override = "public, max-age=31536000, immutable"
393+
headers_set = {}
394+
handler.set_header = lambda k, v: headers_set.update({k: v})
395+
396+
with patch.object(
397+
type(handler).__mro__[1], "finish", lambda self, *a, **kw: None
398+
):
399+
handler.finish()
400+
401+
assert "Cache-Control" not in headers_set
402+
325403

326404
class TestGetHandlers:
327405
"""Test get_handlers() returns correct URL pattern."""

thumbor.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,9 @@ PGTHUMBOR_CACHE_CONTROL_AUTHENTICATED = os.environ.get(
5858
PGTHUMBOR_CACHE_CONTROL_PUBLIC = os.environ.get(
5959
"PGTHUMBOR_CACHE_CONTROL_PUBLIC", ""
6060
)
61+
# 4xx/5xx responses: microcache (default 10s) so a transient error is not
62+
# pinned in downstream HTTP caches for the full max-age of the success path,
63+
# and so downstream caches absorb request floods for broken URLs.
64+
PGTHUMBOR_CACHE_CONTROL_ERROR = os.environ.get(
65+
"PGTHUMBOR_CACHE_CONTROL_ERROR", "public, max-age=10"
66+
)

0 commit comments

Comments
 (0)