Repository navigation
Releases: IT-Cru/thumbor-ai-label
Release list
v0.4.1
Security
This release fixes a memory-exhaustion issue reachable from a crafted source image.
Users running this plugin in production are encouraged to upgrade.
A PNG chunk carries a 32-bit length, and the scanner extracted a metadata payload out of
the buffer before weighing it against ScanLimits. A crafted file could therefore make
the scan allocate several times the file size in order to refuse a payload it was never
going to keep — the budget bounded what was kept, not what was allocated, which is
the opposite of what SECURITY.md claims for it.
The worst case is cheap to build and evades every size limit in the stack, because it is
a metadata chunk rather than image data:
image : 1x1 = 1 pixel
on the wire : 40 MB
peak alloc : 180 MB
MAX_PIXELS defaults to 75 million and the image is one pixel; MAX_WIDTH and
MAX_HEIGHT default to 0; Thumbor has no source-size limit at all. The scan runs in
engine.load, on the raw buffer, before anything else looks at it.
Every published version is affected — 0.1.0 through 0.4.0. There is no configuration that
mitigates it, because the budgets involved are not configurable. Upgrading is the fix.
Exposure depends on who can choose your source bytes. Signed URLs are the effective
control: an attacker who cannot point Thumbor at a file of their choosing cannot reach
this. Deployments running ALLOW_UNSAFE_URL, a permissive ALLOWED_SOURCES, or any
pipeline that scans user uploads are the exposed ones. The issue is availability only —
no disclosure, no code execution.
Details are in the advisory published alongside this release.
What changed
Payloads are now measured where they lie and copied only once the budget has accepted
them. ScanResult.accepts is the single predicate — asked with a length by a walker and
with the bytes by add — so refusing early and refusing late produce the same note, and
no walker can get the order wrong.
Peak allocation while refusing an oversized segment:
| before | after | |
|---|---|---|
PNG raw profile (tEXt) |
216 MB | 0.13 MB |
PNG iTXt |
72 MB | 0.06 MB |
| PNG keyword, delimiter 32 MB in | 32 MB | 0.00 MB |
WebP XMP , WebP EXIF, PNG eXIf |
24 MB | 0.00 MB |
PNG needed the most work: locating a text chunk's header meant copying the whole chunk,
and a raw profile was expanded through a body slice, a split, a joined hex string and an
ASCII decode before anything was weighed. Both are now walked a window at a time — 64 KB,
which is all the residue in the "after" column is — and a raw profile's hex digits are
counted before any of them are decoded.
JPEG needed almost none of it: an APP1 segment is capped at 65535 bytes by the format.
Extended XMP reassembles across segments and is the one JPEG case that can grow; it has
been refused from its declared length since before this.
The measuring pass also got 4.3× cheaper — whitespace is now deleted in a single pass
rather than counted once per whitespace byte — because it runs entirely on
attacker-supplied bytes.
No behaviour change
The same segments are extracted and the same segments refused. Verdicts, labels and
/meta/ payloads are unaffected. One difference is visible only in the scan notes: a PNG
text keyword longer than the 79 bytes the specification allows is now reported as
malformed rather than walked past, which cannot move a verdict — truncated is not read
outside the scanner.
Upgrading
pip install --upgrade thumbor-ai-labelNo configuration changes. Nothing else in this release.
What's Changed
Fixes
Full Changelog: v0.4.0...v0.4.1
v0.4.0
GIF support, end to end: provenance read out of the container, a verdict computed, and a
visible label drawn. None of those three worked before this release, and two of them
failed silently.
Read the upgrade note before taking this. If you serve GIFs, their output changes.
Upgrading from 0.3.0
GIFs now carry a visible label. Under the default strict policy, that includes GIFs
with no provenance metadata at all.
No GIF has ever been labelled by this plugin, in any release — so for anyone serving them
this is new output on existing images, not a refinement of it. With stock configuration:
| Source GIF | Verdict | Visible mark |
|---|---|---|
| carries an AI assertion | ai_generated |
yes |
| carries a camera assertion | (none) | no |
| no provenance metadata | unknown |
yes |
That third row is the one to look at. unknown is this plugin's fail-closed hedge — it
says provenance could not be established, not that the image is AI — and it has always
applied to JPEG, PNG and WebP. GIFs were simply never reaching the code that applies it.
Two ways to change it, both pre-existing keys:
AI_LABEL_POLICY = "relaxed" # only mark what a detector positively asserts
AI_LABEL_DRAW_STATES = ["ai_generated", "ai_manipulated", "ai_composite"] # or drop `unknown`Under either, a plain GIF is served unmarked as before. AI_LABEL_DRAW_STATES keeps the
verdict on /meta/ with "labelled": false, so a CMS can still write the disclosure into
the DOM.
Nothing else changes. Every other format behaves exactly as it did in 0.3.0.
Fixed
The label filter never ran for a GIF — on any engine, in any release.
BaseHandler.after_transform skips the whole post-transform phase when the extension is
.gif and USE_GIFSICLE_ENGINE is not None. It defaults to False, not None, so the
guard was false for every GIF whether or not gifsicle was enabled. No verdict, no label,
and nothing said so. The handler now does both itself for exactly those requests — only
this label, never the rest of the phase.
/meta/ reported detection_disabled for GIFs, which claimed the plugin had been
switched off for an image it had in fact scanned. A CMS reading that field would treat a
gap in coverage as a deliberate configuration choice. It now reports a real verdict.
With USE_GIFSICLE_ENGINE = True, /meta/ reported "labelled": true for GIFs that
carried no mark at all. That engine holds no PIL image, so nothing can be composited onto
it — and a consumer trusting the field would skip the alt text, leaving neither the visible
disclosure nor the accessible one. It now reports false, which is already documented as
"the DOM disclosure is the only one this image has".
The same defect turned every GIF request into a 500 under AI_LABEL_STRICT_ERRORS, and
into a logged AttributeError traceback per request without it. An engine that cannot carry
a mark is a limitation rather than a failure, so it no longer raises — the same treatment as
an image below AI_LABEL_MIN_IMAGE_SIZE — and it is logged once per engine rather than once
per request.
A correction to the 0.3.0 notes
0.3.0 reported that both engine slots are hooked so GIFs are scanned, and that the round
trip was untested because CI had no gifsicle. Both were true. What that release could not
say is that the fix changed nothing observable for GIFs: the scan returned empty, because
there was no GIF walker, and the label could not be drawn, because the filter never ran.
Installing the binary in CI is what surfaced all of it.
New
Provenance is read out of GIF containers
GIF89a stores XMP in an Application Extension labelled XMP DataXMP, and the scanner now
walks it like any other container — so a DigitalSourceType assertion in a GIF reaches the
iptc detector exactly as it does from a JPEG. XMP only: there is no interoperable place
for EXIF in a GIF, and Comment Extensions are unstructured free text.
The walk never decodes a pixel. GIF blocks carry no declared length, so it decodes just
enough structure to step over each one; a 32 MB GIF scans in about 3 ms.
A clearer error when a house icon set is incomplete
Building a set holding only the states you draw fails at boot, and the error now says why:
icon set 'burdastyle' is missing /app/ai-labels/burdastyle/unknown.png; a set needs an
icon for all four label states (ai_generated, ai_manipulated, ai_composite, unknown),
including states left out of AI_LABEL_DRAW_STATES - so that key can be changed later
without rebuilding the set
The behaviour is unchanged — the same sets load and the same sets fail. Previously the
message was accurate and silent on the one question its reader had, and with strict errors
off that meant every request served unlabelled.
Known constraints
USE_GIFSICLE_ENGINE = True still means GIFs carry no visible mark. That engine holds
no image in memory, so there is nothing to draw on. The verdict is still detected and
published with "labelled": false, which makes the /meta/ disclosure the only one those
images carry. On the default PIL engine GIFs are labelled, every frame of an animation
included.
Testing
CI now installs gifsicle and drives real GIFs through the engine that shells out to it, so
the path 0.3.0 shipped untested is exercised on every run. Coverage remains at 100%.
What's Changed
Features
Fixes
- fix: stop reporting GIFs as labelled when the gifsicle engine cannot draw by @IT-Cru in #20
- fix: compute the verdict and draw the label for GIFs, which Thumbor skips entirely by @IT-Cru in #27
Maintenance
- chore: install gifsicle in CI and exercise the GIF path through it by @IT-Cru in #23
- chore: group the generated release notes by change type by @IT-Cru in #30
- chore: document the pull request title convention beside branch naming by @IT-Cru in #31
- chore: say why an icon set needs all four states by @IT-Cru in #32
Full Changelog: v0.3.0...v0.4.0
v0.3.0
The plugin no longer takes Thumbor's ENGINE slot for itself, so it can run alongside a
custom engine. APP_CLASS is now the whole integration.
Nothing is required of you on upgrade — the old two-key config keeps working unchanged.
Upgrading from 0.2.0
Optional, and the point of the release. You can drop this line:
ENGINE = "thumbor_ai_label.engine" # no longer needed with APP_CLASSAPP_CLASS now installs the metadata-scan hook itself, by subclassing whichever engine you
have configured at startup. Removing the line frees the slot for your own engine; leaving it
in place is harmless, because the app skips a slot that already carries the hook.
Keep it in exactly one case: a filter-only setup that does not set APP_CLASS and puts
ai_label() in URL rules by hand. There is no app there to install the hook, so that setup
still names the engine explicitly.
Fixed
GIFs were never scanned when USE_GIFSICLE_ENGINE = True. GIF_ENGINE is a separate
config slot, and the plugin only ever hooked ENGINE — so with gifsicle enabled, GIFs
loaded through an unhooked engine and no provenance metadata was read at all. Nothing in the
log said so. Under AI_LABEL_POLICY = "relaxed" an AI-generated GIF was served unlabelled;
under strict it was marked unknown rather than ai_generated.
Both engine slots are hooked now. This affected every release up to and including v0.2.0.
Note on coverage, since it is the honest state of it: the slot wrapping is tested, but
loading a GIF through it needs the gifsicle binary, which CI does not install — so that
round trip is not exercised anywhere in the test suite.
New
Run any engine
ENGINE = "my.engine"
APP_CLASS = "thumbor_ai_label.app.AiLabelServiceApp"Previously impossible: ENGINE is a single slot, so pointing it at this plugin displaced
whatever engine you were running — thumbor-video-engine, a libvips engine, an in-house one.
The only escape was hand-writing a subclass. The app now composes with the configured engine
instead, and covers GIF_ENGINE alongside it.
Why the hook has to live in the engine at all, for anyone who has wondered: engine.load()
is the only point in Thumbor's request flow that always receives the original bytes. On a
storage hit the handler loads a cached original; on a miss the fetch path loads what the
loader returned and then discards the buffer. No handler-level hook sees both, and a filter
runs after the image is decoded, by which time the metadata is gone.
The startup log says which engine is hooked
[AiLabel] engine hook installed on thumbor.engines.pil.Engine, thumbor.engines.gif.Engine
A wrapped third-party engine is indistinguishable from an unwrapped one from the outside, so
this line is the only place that says which one is carrying the scan. A failure to install
the hook is logged as an error and is fatal only under AI_LABEL_STRICT_ERRORS, matching how
invalid configuration is already handled.
Docs
README.md, the documentation home page and the getting-started guide all led with two
required keys. They now lead with one, and the engine line appears only under the
filter-only setup, with an upgrade note.
What's Changed
Full Changelog: v0.2.0...v0.3.0
v0.2.0
Two new configuration keys for controlling what gets labelled and with which artwork, a
redraw of the bundled icon sets, and a new light-on-dark variant.
Read the upgrade notes before taking this. Two changes alter the behaviour of a working
0.1.1 install: one config value that used to be accepted is now an error, and the default
artwork has changed.
Upgrading from 0.1.1
A falsy icon override is now an error
AI_LABEL_ICONS = {"unknown": ""} — and None, False, 0 — used to fall through to the
bundled icon. It now raises at startup:
IconError: unusable icon override for 'unknown' = ''; a path is required.
To leave a state unmarked, drop it from AI_LABEL_DRAW_STATES instead.
With AI_LABEL_STRICT_ERRORS off, which is the default, that means
[AiLabel] configuration is invalid; labelling will not work in the log and every image
served unlabelled — not just the state you were trying to suppress.
If you used a falsy value as an off switch, remove the entry and use the new
AI_LABEL_DRAW_STATES instead. It is the key that actually does what those values were
reaching for; before this release, none of them did — they silently drew the default mark.
The bundled artwork moved, and default looks different
The icon sets are now one directory per set at thumbor_ai_label/labelsets/<set>/. In 0.1.1
the default icons sat loose in thumbor_ai_label/icons/, which no longer exists. If
anything of yours points into the installed package — an AI_LABEL_ICONS path, a Dockerfile
COPY, a bind mount — repoint it. AI_LABEL_ICON_DIR below is the supported way to do this
now.
The default marks were also redrawn, so output pixels change on upgrade:
unknownwas not a question mark. The bowl was drawn with an arc and the stem as a
separate centred line, and the two never met — it rendered as a hook floating above a dot.
It is now one continuous polyline.ai_compositeandai_manipulatedwere not distinguishable at the sizes actually
drawn. The composite's 50° ring gaps closed up under LANCZOS on a 20 px icon, and the
manipulated badge was a bump below 48 px that broke the disc silhouette. Ring gaps are now
90° or wider and the badge is gone, leaving three distinct silhouettes — closed,
open-bottom, open-sides.
The eu and eu-white sets are unchanged apart from eu-white now taking its neutral
unknown mark from the light artwork, so it no longer pairs white official labels with a
dark mark.
New
AI_LABEL_DRAW_STATES — which states get a visible mark
AI_LABEL_DRAW_STATES = ["ai_generated", "ai_manipulated", "ai_composite"]Narrows what is burnt into the pixels. None, the default, draws all four, so nothing
changes unless you set it. The common narrowing drops unknown — the fail-closed hedge for
provenance that could not be established, which is not an AI claim and which a publisher may
reasonably decide not to mark.
Suppression is about pixels only. Detection still runs and /meta/ still publishes the
verdict, with "labelled": false beside its disclosure string — so the label your CMS writes
into the DOM becomes the entire disclosure for those images. Narrowing this list is a
decision about your Article 50(4) obligations, so it is announced in the log at startup.
An empty list is a valid meta-only mode: verdicts published, no pixels touched. Being a list
of plain strings, it can also be set from the environment as
AI_LABEL_DRAW_STATES=ai_generated,ai_manipulated.
AI_LABEL_ICON_DIR — a house icon set as a mounted directory
AI_LABEL_ICON_DIR = "/etc/thumbor/icon-sets"
AI_LABEL_ICON_SET = "house-style"AI_LABEL_ICON_SET has always named a set; it can now name one of yours. A set is a
subdirectory of AI_LABEL_ICON_DIR holding the same four filenames, so your artwork resolves
exactly like a bundled set — one mounted volume and one name, rather than a rebuild to get
files into the installed package or four absolute paths in AI_LABEL_ICONS. Both keys are
plain strings, so both survive a thumbor.conf rendered from the environment, which the
dict-valued AI_LABEL_ICONS cannot do.
Two things to know when building a set:
- It needs all four icons —
ai_generated.png,ai_manipulated.png,ai_composite.png,
unknown.png— even for states you left out ofAI_LABEL_DRAW_STATES. A set is validated
in full at startup so that changingAI_LABEL_DRAW_STATESlater cannot break it. A missing
file fails config resolution, which means everything serves unlabelled, so it is worth
checking the boot log after a set change. - While
AI_LABEL_ICON_DIRis set, the bundled names do not resolve. Sets are looked up
there and nowhere else, and there is no fallback: a typo in a house-style name has to fail
loudly rather than quietly ship this plugin's default marks under your byline — a wrong
label is worse than a missing one. Copy a bundled set into your directory if you want it
alongside your own.
default-light
AI_LABEL_ICON_SET = "default-light"This plugin's own marks in light-on-dark, for dark imagery. Previously only the eu sets
offered both polarities. The hues are darkened rather than reused, since the dark set's
colours fall below 3:1 contrast on near-white.
Also
- The startup log now names the icon set in use —
[AiLabel] ready: … icon_set=default ….
WithAI_LABEL_ICON_DIR,defaultno longer means one fixed set of artwork, and a mark
drawn from the wrong directory looks entirely correct in the output. - Unknown-icon-set errors now name the directory that was actually searched, and which key
to reach for. The old message named a set as both missing and bundled in the same sentence,
which reads as a broken install. AI_LABEL_ICON_SETandAI_LABEL_ICON_DIRread an empty value as "not configured" and
strip surrounding whitespace, so an unrendered template variable does not become a
directory name. Found by Copilot's review on #14.- PyPI's sidebar links now point at the documentation site rather than repeating the
repository URL twice. Metadata only, from #8; this is the release where it takes effect. - CI now asserts the built wheel actually contains the icon artwork. The test suite cannot
catch a broken packaging map — tests run against a checkout, where the artwork is found in
place, so a wheel could have shipped with no icons at all and every test would still pass.
What's Changed
- Point PyPI at the documentation site by @IT-Cru in #8
- Reorganise and optically fix the AI label icon sets by @IT-Cru in #10
- Let config decide which states get a visible mark by @IT-Cru in #13
- Let a house icon set ship as a mounted directory by @IT-Cru in #14
Full Changelog: v0.1.1...v0.2.0
v0.1.1
v0.1.0rc1
Full Changelog: https://github.com/IT-Cru/thumbor-ai-label/commits/v0.1.0rc1