Allow the decompression-bomb limit to be scoped per call / per context, not only via the interpreter-global `Image.MAX_IMAGE_PIXELS`
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
调研方向
Start with _decompression_bomb_check() in src/PIL/Image.py, then inspect the Image type stub and Tests/test_decompression_bomb.py. The issue proposes a scoped pixel limit with the global limit as the default; verify that strict and permissive concurrent jobs retain their own limits and existing global behavior remains compatible. Add the proposed tests, documentation, and release note.
由索引模型根据 Issue 内容生成。
描述
What did you do?
Ran two jobs with different decompression-bomb limits in one Python process — the situation OCRmyPDF documents in its v17.11.0 release notes (https://github.com/ocrmypdf/OCRmyPDF/releases/tag/v17.11.0):
"Known limitation of concurrent in-process jobs: they must use the same
max_image_mpixels. Pillow's decompression-bomb limit is interpreter-global and the last job to set it wins. Use separate processes to run jobs with differing configurations."
OCRmyPDF's own workaround is to tell users to run separate processes. The underlying problem is in Pillow: PIL.Image.MAX_IMAGE_PIXELS is a single module global, _decompression_bomb_check() reads it at whatever moment each format plugin happens to run the check, and Image.open() (fp, mode, formats) offers no way to scope a limit to one call, one image, or one job.
Minimal reproduction (stock Pillow 12.3.0). A strict job (1M px limit, untrusted uploads) and a permissive job (20M px limit, its own large scans) each set the global once at start-up, then open the same 9M px image:
import io, threading, warnings
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (3000, 3000)).save(buf, format="PNG") # 9,000,000 pixels
data = buf.getvalue()
def outcome():
with warnings.catch_warnings():
warnings.simplefilter("error")
try:
with Image.open(io.BytesIO(data)) as im:
im.load()
return "OPENED"
except Image.DecompressionBombError:
return "REJECTED"
barrier = threading.Barrier(3)
results = {}
def job(name, limit):
Image.MAX_IMAGE_PIXELS = limit # set once, at job start-up
barrier.wait() # both jobs have "configured" themselves
results[name] = outcome()
t1 = threading.Thread(target=job, args=("permissive", 20_000_000))
t2 = threading.Thread(target=job, args=("strict", 1_000_000))
t1.start(); t2.start(); barrier.wait(); t1.join(); t2.join()
print(results)
What did you expect to happen?
The permissive job opens its image and the strict job rejects the same image: each job's limit applies to its own work.
What actually happened?
Whichever job set the global last governs both jobs. With the strict job setting last, the result is {'permissive': 'REJECTED', 'strict': 'REJECTED'} — the permissive job's legitimate image fails. Reverse the set order and it is {'permissive': 'OPENED', 'strict': 'OPENED'} — the strict job's protection silently disappears; nothing in its code changed and nothing warns it. The same interference happens without threads: any library that raises or lowers the global between another component's start-up and its next open() silently rewrites that component's policy. Re-setting the global immediately before each open() does not fix it either, because another thread can set it again between the set and Pillow's check, and checks also run at seek/load time for some formats.
Related history I found before filing: #9029 asked for the limit to be read from an environment variable and was closed after a set-it-at-start-up workaround was suggested — that still leaves a single process-wide value. #4939 made the BMP limit configurable in terms of the same global. Neither gives concurrent in-process components independent limits.
Proposal
Add a scoped form of the limit that leaves the global as the default. The least invasive shape appears to be a context manager backed by contextvars, e.g.:
with Image.limit_pixels(1_000_000):
im = Image.open(untrusted_upload)
_decompression_bomb_check() is the single enforcement point — every format plugin calls it — so it would consult the scoped value when one is active and fall back to MAX_IMAGE_PIXELS otherwise: one function changes, no plugin call sites change. contextvars gives the semantics the use case needs for free: each thread runs in its own context, so one job's scope cannot leak into another's; async tasks copy the context at creation; scopes nest and restore on exit. A scoped value of None would disable the checks inside the scope, matching the global's existing convention.
Alternative considered: a keyword on Image.open(), e.g. Image.open(fp, max_pixels=...). It reads naturally for the open-time check, but open() is lazy — plugins also run the check while seeking (e.g. GIF frames) and loading, where a per-open value would have to be stored on the image and passed from ~30 plugin call sites; it also would not cover the font-file checks that run outside any opened image. The context manager covers all of these through the one check function. If maintainers prefer the keyword shape anyway, the same fallback logic applies.
Compatibility: with no scope active, behaviour is byte-for-byte what it is today, including the warning threshold (limit), the error threshold (2× limit), and MAX_IMAGE_PIXELS = None disabling checks. Code that sets the global keeps working unchanged.
If the maintainers are open to this, I am willing to implement it: I have a working prototype (a ~40-line change to src/PIL/Image.py, plus the Image.pyi stub) in which the strict/permissive scenario above passes with each job in its own scope, and I would add tests in Tests/test_decompression_bomb.py, documentation, and a release note per the contributing guidelines.
What are your OS, Python and Pillow versions?
- OS: Linux
- Python: 3.12
- Pillow: 12.3.0
Disclosure: this contribution was written by Árbol, an AI assistant working under the direction of the account holder, who set the acceptance criteria and verified the results before submission.
- 主要语言
- Python
- 星标
- 13.9k
- 派生
- 2.6k
- 平均合并
- 1 天 2 小时
- 30 天内合并 PR
- 176
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
python-pillow/Pillow 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 72/100
python-pillow/Pillow#10370 · 1 条评论 ·
维护者通常 1 天内回复
-
Warn that pre-applying arabic_reshaper + python-bidi corrupts text on a Raqm build可能已有人在做 @Syamjith-NK 于 63 天前认领。 未关闭Documentation Font Rendering PNG Third Party Framework
难度 2/5 1-3 小时 新手友好度 78/100
python-pillow/Pillow#9907 · 2 条评论 ·
维护者通常 1 天内回复
-
Image.putpixel incorrectly labels supported arguments in type annotations可能已有人在做 @radarhere 于 191 天前认领。 未关闭PNG
难度 2/5 1-3 小时 新手友好度 70/100
python-pillow/Pillow#9584 · 1 条评论 ·
维护者通常 1 天内回复
-
`ImageDraw.arc()` segfaults on a tuple subclass whose `__len__` is too large可能已有人在做 @Parsiffall1 于 2 天前认领。 未关闭
难度 4/5 1-2 天 新手友好度 58/100
python-pillow/Pillow#10358 · 2 条评论 ·
维护者通常 1 天内回复
-
JPEG saved with keep_rgb=True and dpi reopens with incorrect colors可能已有人在做 @Muguett-DBY 于 3 天前认领。 未关闭🤖-assisted JPEG
难度 4/5 1-2 天 新手友好度 25/100
python-pillow/Pillow#10344 ·
维护者通常 1 天内回复
查看 python-pillow/Pillow 的全部 Issue
相似的 Issue
-
bug
难度 2/5 1-3 小时 新手友好度 74/100
BCDA-APS/polar-bits#93 ·
-
infer_audio_type only recognises one MPEG Layer III frame header, so most MP3 files are rejected可能已有人在做 @Steve-20-ai 今天认领。 未关闭
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 1 天内回复
-
[BUG] - ssh_config `Match tagged` statement always applies可能已有人在做 @AtharvRG 今天认领。 未关闭Bug
难度 2/5 1-3 小时 新手友好度 68/100
paramiko/paramiko#2715 · 1 条评论 ·
维护者通常 9 天内回复
-
auto-rejected intake mcp-intake needs-salvage priority:high type:feature
难度 2/5 1-3 小时 新手友好度 65/100
Ikalus1988/MisakaNet#4221 · 2 条评论 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复