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_KEY environment variable (or Plone registry).

  • Thumbor side: THUMBOR_SECURITY_KEY in thumbor.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

{blob_zoid}/{tid}

2

None

Public images: skip auth overhead

{blob_zoid}/{tid}/{content_zoid}

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:

  1. 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.

  2. Auth subrequest. For 3-segment URLs, the handler makes an HTTP request to Plone’s @thumbor-auth endpoint:

    GET {PGTHUMBOR_PLONE_AUTH_URL}/@thumbor-auth?zoid={content_zoid_hex}
    

    The browser’s Cookie and Authorization headers are forwarded in this subrequest, so Plone authenticates the request as the original user.

  3. Response handling. If @thumbor-auth returns 200, the request proceeds to the normal imaging pipeline. Otherwise, Thumbor returns 403 Forbidden.

  4. Result caching. Auth results are cached in memory per (content_zoid_hex, cookie_header) for PGTHUMBOR_AUTH_CACHE_TTL seconds (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

  1. Parse the ZOID. The zoid query parameter is a hexadecimal OID. Invalid or missing values return 400 Bad Request.

  2. Compute user principals. The current user (authenticated via the forwarded Cookie or Authorization header) is obtained from the Zope security manager. catalog._listAllowedRolesAndUsers(user) computes the user’s effective principals – their roles, group memberships, and the special Anonymous and Authenticated tokens that Plone’s security indexes use.

  3. Single PG query. The service checks whether the object’s allowedRolesAndUsers JSONB 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’s allowedRolesAndUsers KeywordIndex lookup, which checks whether any of the user’s principals appear in the object’s allowed list.

  4. 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 (content_zoid, cookie) – different users get different cache entries

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

get_thumbor_config() returns None, Plone falls back to standard image serving

1

Database unavailable for auth check

_needs_auth_url() fails safe (uses 3-segment URL); @thumbor-auth returns 503

2 + 3