<!-- diataxis: explanation -->

# 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:

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

```python
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:

   ```sql
   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.

## Cookie forwarding and session sharing

The auth flow depends on the browser's session cookie reaching Plone through
Thumbor.
The request chain is:

```{mermaid}
sequenceDiagram
    participant B as Browser
    participant N as nginx
    participant T as Thumbor
    participant P as Plone (internal)

    B->>N: GET /thumbor/... (Cookie: __ac=...)
    N->>T: proxy (Cookie header preserved)
    T->>P: GET /@thumbor-auth?zoid=... (Cookie: __ac=... forwarded)
    P->>P: authenticate user from cookie
    P->>T: 200 OK
```

This works because the browser, nginx, and Plone all share the same domain (or at
least the same cookie domain).
The browser sends its session cookie to nginx, nginx
forwards it to Thumbor (it passes all request headers), and Thumbor's
`AuthImagingHandler` forwards the `Cookie` and `Authorization` headers to Plone.

Plone's `extractCredentials()` plugins process the cookie as if the request came
directly from the browser.
The user is authenticated with their full session,
including any Zope roles, group memberships, and local roles.

### Internal network routing

The auth subrequest URL (`PGTHUMBOR_PLONE_AUTH_URL`) should point to Plone's
internal address -- not through the reverse proxy.
In the Docker Compose example:

```
PGTHUMBOR_PLONE_AUTH_URL = http://plone:8080/Plone
```

This bypasses nginx entirely.
If the auth URL went through nginx, the request would
loop: nginx routes to Thumbor, Thumbor routes back to nginx for auth, potentially
creating a request loop or at minimum adding unnecessary latency.

The internal URL includes the Plone site path (`/Plone`) because without
VirtualHostMonster rewriting, Plone's site root is at `/Plone`, not `/`.

## 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 |
