Security model¶
plone.pgthumbor sits between two trust boundaries: the browser (untrusted) and the image processing pipeline (trusted). Without safeguards, an attacker could request arbitrary image transformations (a denial-of-service vector), access images they are not authorized to see, or bypass Plone’s content security entirely.
The security model addresses these threats with three layers: HMAC-signed URLs to prevent URL tampering, an authentication handler in Thumbor to enforce Plone access control, and a lightweight REST service in Plone that checks permissions without loading ZODB objects. This page explains each layer, how they interact, and the reasoning behind the design.
Layer 1: HMAC-signed URLs¶
The threat¶
Thumbor is a general-purpose image processor. Given an unprotected Thumbor server, anyone can construct URLs that request arbitrary operations: resize to enormous dimensions, apply expensive filters, or repeatedly request different transformations of the same image. This is a classic amplification attack – a cheap HTTP request triggers expensive server-side computation.
The mitigation¶
Every Thumbor URL generated by Plone is signed with HMAC-SHA1 using a shared secret key. The URL structure is:
/thumbor/{hmac_signature}/{width}x{height}/{options}/{blob_zoid}/{blob_tid}
The HMAC covers the entire path after the signature – dimensions, fit-in/smart flags, and the image identifier. Thumbor verifies the signature before processing. A request with a missing, invalid, or tampered signature is rejected immediately.
thumbor_url() in url.py delegates signing to libthumbor’s CryptoURL class,
which implements Thumbor’s standard HMAC-SHA1 signing protocol.
The same
security_key must be configured on both sides:
Plone side:
PGTHUMBOR_SECURITY_KEYenvironment variable (or Plone registry).Thumbor side:
THUMBOR_SECURITY_KEYinthumbor.conf.
Unsafe mode¶
For development and testing, PGTHUMBOR_UNSAFE=true generates unsigned URLs with
the /unsafe/ prefix instead of an HMAC signature.
Thumbor must also have
ALLOW_UNSAFE_URL = True in its configuration.
Never enable unsafe mode in production. Without HMAC verification, any client can construct arbitrary Thumbor URLs, bypassing rate limiting and enabling transformation abuse.
get_thumbor_config() in config.py enforces this: if neither security_key is
set nor unsafe is enabled, it returns None, which disables Thumbor URL
generation entirely.
Plone falls back to standard image serving.
Layer 2: auth handler in Thumbor¶
The threat¶
HMAC signing prevents URL tampering, but it does not enforce who may see an image. In Plone, content can be private (visible only to specific roles), restricted by workflow state, or subject to publication date ranges. A signed URL for private content is still a valid URL – if the browser can reach Thumbor, it can load the image.
The mitigation¶
The AuthImagingHandler in zodb-pgjsonb-thumborblobloader intercepts Thumbor
requests and enforces Plone access control for images that require it.
The system uses two URL formats to distinguish public from restricted content:
URL format |
Segments |
Auth check |
Example |
|---|---|---|---|
|
2 |
None |
Public images: skip auth overhead |
|
3 |
Yes |
Restricted images: verify with Plone |
Plone decides which format to use at URL generation time (in
ThumborImageScale.__init__()).
The decision logic in _needs_auth_url() is:
If paranoid mode is enabled in the Plone registry, always use 3-segment
(check auth for everything).
2.
Otherwise, query PostgreSQL directly: does the object’s
idx->'allowedRolesAndUsers' JSONB array contain 'Anonymous'?
Yes -> content is public, use 2-segment URL.
No -> content is restricted, use 3-segment URL.
If the query fails for any reason, fail safe – use 3-segment URL.
How the auth handler works¶
AuthImagingHandler extends Thumbor’s ImagingHandler and overrides the get()
method:
URL inspection.
_extract_content_zoid()checks whether the last three path segments are all valid hexadecimal strings. If yes, the URL is 3-segment and the last segment is the content ZOID. If only the last two are hex, the URL is 2-segment and no auth check is needed.Auth subrequest. For 3-segment URLs, the handler makes an HTTP request to Plone’s
@thumbor-authendpoint:GET {PGTHUMBOR_PLONE_AUTH_URL}/@thumbor-auth?zoid={content_zoid_hex}The browser’s
CookieandAuthorizationheaders are forwarded in this subrequest, so Plone authenticates the request as the original user.Response handling. If
@thumbor-authreturns 200, the request proceeds to the normal imaging pipeline. Otherwise, Thumbor returns 403 Forbidden.Result caching. Auth results are cached in memory per
(content_zoid_hex, cookie_header)forPGTHUMBOR_AUTH_CACHE_TTLseconds (default: 60). This avoids a Plone round-trip on every image tile when a page contains many images from the same content object.
Handler registration¶
The auth handler is registered via Thumbor’s HANDLER_LISTS configuration:
HANDLER_LISTS = ['zodb_pgjsonb_thumborblobloader.auth_handler']
get_handlers() returns a list of (url_regex, handler_class, context) tuples
using Thumbor’s standard Url.regex() pattern.
This replaces the default
ImagingHandler with AuthImagingHandler for all image URLs.
Layer 3: @thumbor-auth REST service¶
The threat¶
A naive auth check would load the ZODB object, wrap it in Acquisition context,
and call checkPermission().
This is expensive – it involves ZODB ghost
activation, security proxy construction, and potentially deep Acquisition chains.
For a page with 30 images, that is 30 ZODB loads just for access checks.
The mitigation¶
ThumborAuthService in restapi.py implements a lightweight access check using
a single PostgreSQL query.
It never loads ZODB objects and never touches the Zope
security manager for the content object itself.
The endpoint is registered as a plone.rest service on INavigationRoot:
GET /@thumbor-auth?zoid={hex_oid}
How it works¶
Parse the ZOID. The
zoidquery parameter is a hexadecimal OID. Invalid or missing values return 400 Bad Request.Compute user principals. The current user (authenticated via the forwarded
CookieorAuthorizationheader) is obtained from the Zope security manager.catalog._listAllowedRolesAndUsers(user)computes the user’s effective principals – their roles, group memberships, and the specialAnonymousandAuthenticatedtokens that Plone’s security indexes use.Single PG query. The service checks whether the object’s
allowedRolesAndUsersJSONB array overlaps with the user’s principals:SELECT (idx->'allowedRolesAndUsers' ?| %s::text[]) AS allowed FROM object_state WHERE zoid = %s
The
?|operator checks whether the JSONB array contains ANY of the given text values. This is equivalent to Plone’sallowedRolesAndUsersKeywordIndex lookup, which checks whether any of the user’s principals appear in the object’s allowed list.Response.
Condition
Status
Meaning
Object found, principals overlap
200
User may view
Object found, no overlap
401
User may not view
Object not in catalog
404
Unknown object
Database error
503
Service unavailable
Why zope2.Public permission¶
The @thumbor-auth service is registered with permission="zope2.Public" – it
is accessible without authentication.
This is intentional: the service itself
performs the access check internally using the forwarded session credentials.
If it
required authentication to call, the Thumbor handler (which is not a Zope user)
could not reach it.
The security of the endpoint rests on its design: it only answers “may user X view object Y?” and returns a boolean. It does not return object data, metadata, or any information beyond the yes/no answer.
Paranoid mode¶
By default, plone.pgthumbor only appends the content ZOID (triggering auth checks) for content that is not publicly accessible. This is an optimization: public images skip the auth round-trip entirely.
Paranoid mode (IThumborSettings.paranoid_mode = True) forces all image URLs to use
the 3-segment format, regardless of the object’s allowedRolesAndUsers.
Every image
request goes through the auth check.
When to enable paranoid mode:
High-security environments where the cost of a single leaked private image outweighs the latency of per-request auth checks.
Untrusted admin environments where site administrators might accidentally publish content that should remain private (the auth check catches the mistake on the Thumbor side even if the catalog index is wrong).
Development and testing to verify that the auth flow works correctly.
The overhead of paranoid mode is one cached HTTP subrequest per image per TTL
window.
With PGTHUMBOR_AUTH_CACHE_TTL = 60, a page with 20 images from 5
different content objects makes at most 5 subrequests per minute.
Threat model summary¶
Threat |
Mitigation |
Layer |
|---|---|---|
Arbitrary transformation requests (DoS) |
HMAC-signed URLs |
1 |
URL parameter tampering |
HMAC covers full path |
1 |
Unauthorized access to private images |
3-segment URL + auth handler |
2 |
Expensive auth checks (ZODB load per image) |
Direct PG query on JSONB index |
3 |
Session hijacking on auth subrequest |
Forward original Cookie, internal network |
2 + network |
Auth cache poisoning |
Cache keyed on |
2 |
Leaked signed URL for private content |
URL is time-limited by TID (content changes invalidate URL); auth check blocks reuse by unauthorized users |
1 + 2 |
Missing Thumbor config |
|
1 |
Database unavailable for auth check |
|
2 + 3 |