Conversation
sync with tests
* add the modern CIP labeller
* fix path
* push fix
* push additional stereo audit
* feat: add complete audit
* ruff
* push website code
* website: default OPSIN verify, squared redesign, atom-id depiction, version tag
- OPSIN round-trip verification is now on by default
- flat/squared restyle: no rounded corners, left-aligned header
- long names and atom-id lines wrap instead of overflowing
- new /api/depict endpoint renders the molecule with RDKit atom
indices; shown in the "Explain this name" section
- pin openclatura[web]==0.1.4 and show the installed version in the
header (healthz override reports distribution metadata, since the
0.1.4 wheel hardcodes __version__ = "0.1.0")
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: bundle X11 libs so rdkit depiction works on Vercel
rdkit.Chem.Draw dlopens a bundled libcairo that needs libXrender &
friends, absent from the Vercel runtime image. Bundle the closure
(Debian bullseye builds, glibc<=2.26 symbols, compatible with the
AL2023 runtime) and ctypes-preload them before the Draw import.
The import is now also non-fatal: without it /api/depict degrades
instead of crashing every endpoint at cold start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: add libexpat to bundled X11 libs
fontconfig inside the rdkit wheel needs libexpat.so.1, also absent
from the Vercel runtime image.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: serialize OPSIN verification to fix intermittent errors
py2opsin writes a fixed-name temp file in the CWD; concurrent requests
in one warm function process collide on it and the round-trip raises,
surfacing as status "error". Shadow /api/name with a version that holds
a process-wide lock around verify_opsin runs and retries once.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: cache naming/describe/depict results in Upstash Redis
Results are deterministic per package version, so responses are cached
under {endpoint}:{version}:{flags}:{sha256(canonical SMILES)} with a
90-day TTL via the Upstash REST API (stdlib urllib, no new deps).
Failures and OPSIN error/skipped statuses are never cached; any cache
error falls back to computing. Without KV_REST_API_URL/TOKEN the cache
is a no-op, so the deploy is safe before the store is provisioned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: add S3-compatible cache backend (own bucket)
The result cache can now use any S3-compatible object store via
stdlib SigV4 signing (CACHE_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY;
AWS_* names accepted for local runs but reserved on Vercel). Upstash
REST remains as fallback backend. Expiry via bucket lifecycle rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: persistent OPSIN JVM per instance
OPSIN's CLI streams names line-by-line, so keep one JVM alive per
function instance instead of booting one per verification (~1.5 s
-> ~10 ms warm). Comparison logic mirrors verify_with_opsin; on any
daemon hiccup the process is killed and the request falls back to
the one-shot py2opsin path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: add stdlib-only cache size report script
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: warm backend while Ketcher loads
New GET /api/warmup boots the OPSIN JVM and primes the naming engine;
the frontend fires it on page load so the serverless cold start
overlaps with editor initialization instead of delaying the first
naming request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* website: fix white editor backdrop in dark mode
Ketcher themes its own canvas, so the hardcoded white .editor-shell
background showed as a sliver around the iframe (and the loading
overlay flashed white) for dark-mode visitors. Drive both from a
--editor-bg variable instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* website: wait 1.5s after the last edit before naming
600ms fired while people were still drawing, naming half-finished
structures (and spending a request on each). Raise the edit debounce
to 1.5s behind a named constant, show "waiting for you to finish
drawing…" while it counts down, and clear that status on edits that
don't change the structure. Example chips keep their short wait since
they load complete molecules.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* website: 900ms debounce, and don't flash errors mid-drawing
The 1.5s wait was masking the real problem: a failed intermediate
naming wiped the panel and showed a red error, so any request on a
half-drawn structure looked broken. Instead:
- keep the last good name on screen (dimmed) while a request is in
flight, rather than blanking the panel
- hold naming failures behind a 2s grace window that any further edit
cancels, so only a drawing the user has stopped touching reports an
error
- with failures no longer jarring, drop the debounce to 900ms
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* revert to main
* drop irrelevant files for this branch
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
speed-up computation 2x (add caching)
There was a problem hiding this comment.
Sorry @AdrianM0, your pull request is larger than the review limit of 300000 diff characters
📝 WalkthroughWalkthroughThe change revises naming assembly and analysis internals, introduces request-scoped token-span control and caching, removes legacy wrappers, and adds a Vercel-hosted Ketcher interface with cached naming, description, depiction, warmup, health, and OPSIN verification endpoints. ChangesNaming engine changes
Website application
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.44.1)website/public/ketcher/static/js/main.cb80d824.jsast-grep skipped this file: it is too large to scan (29521424 bytes) website/public/ketcher/static/js/535.c5fc3b49.chunk.jsast-grep timed out on this file website/public/ketcher/static/js/622.ed91acd0.chunk.jsast-grep timed out on this file Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (2)
src/openclatura/assembler.py (1)
148-233: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a regression test covering the heteroaryl-methyl-in-group contraction.
The paren-aware helper avoids the inner
)methylfalse match in(oxo)(1-((thiophen-2-yl)methyl)cyclohexyl)methyl, but no direct regression test exercises this exact failure mode.Suggested test to add
def test_oxo_group_methyl_to_carbonyl_handles_nested_methyl_group(): assert _oxo_group_methyl_to_carbonyl( "(oxo)(1-((thiophen-2-yl)methyl)cyclohexyl)methyl" ) == "(1-((thiophen-2-yl)methyl)cyclohexyl)carbonyl"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/openclatura/assembler.py` around lines 148 - 233, The paren-aware _oxo_group_methyl_to_carbonyl helper lacks regression coverage for nested heteroaryl-methyl groups. Add a focused test for _oxo_group_methyl_to_carbonyl using "(oxo)(1-((thiophen-2-yl)methyl)cyclohexyl)methyl" and assert it returns "(1-((thiophen-2-yl)methyl)cyclohexyl)carbonyl".website/api/index.py (1)
140-186: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated AWS SigV4 signing implementation across two files. Both files independently hand-roll the same SigV4 canonical-request/signing-key derivation (deliberately avoiding boto3 to keep the Vercel bundle small), so a correctness fix or algorithm change in one would need to be manually mirrored in the other.
website/api/index.py#L140-L186: extract the SigV4 signing steps (canonical request, scope, derived key, signature) into a small shared stdlib-only helper module importable by both this file and the script.website/scripts/cache_size.py#L30-L63: replaces3_get's inline signing logic with the same shared helper, keeping only the GET-with-query-string specifics local to this script.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@website/api/index.py` around lines 140 - 186, Extract the shared AWS SigV4 canonical-request, credential-scope, signing-key, and signature logic from _s3_request in website/api/index.py (lines 140-186) into a small stdlib-only helper importable by both files. Update website/scripts/cache_size.py (lines 30-63) to replace s3_get’s duplicated signing implementation with that helper, retaining only its GET query-string handling locally; update _s3_request to use the same helper as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/openclatura/name_assembly.py`:
- Around line 15-28: The global _BUILD_TOKEN_SPANS toggle in
set_token_span_building is unsafe for concurrent NamingEngine.run requests.
Replace it with thread/task-local request context, ensure
set_token_span_building preserves and returns the caller’s prior context value,
and update the token-span construction path to read that context so each request
independently controls span building.
In `@src/openclatura/namer.py`:
- Around line 1238-1245: Update the amido prefix concatenation in the
n_substituents branch to insert a hyphen when amido begins with either a digit
or a parenthesized stereo descriptor, while preserving the existing no-hyphen
behavior for other prefixes. Use the existing amido and _format_n_locant_prefix
logic without changing downstream complex-prefix formatting.
In `@website/api/index.py`:
- Around line 406-419: Update describe_endpoint to handle exceptions raised by
describe(req.smiles), returning the same {"ok": False, "error": ...} structured
response used by depict for unparsable SMILES instead of propagating a generic
500. Preserve the existing cache lookup and successful-response caching
behavior.
- Around line 249-403: Extend the OPSIN concurrency protection beyond
name_endpoint: ensure name_many batch execution with processes != 1 cannot
invoke verify_with_opsin/py2opsin concurrently across worker processes. Prefer
routing verification through a shared serialized daemon, or disable/offload
verification for that multi-process path while preserving existing behavior for
single-process and non-batch requests.
- Around line 58-90: Harden both tar extraction sites in _preload_xlibs and
_ensure_java by validating archive member paths before extraction, rejecting any
entry that resolves outside the staging directory. Apply the same
safe-extraction approach to both tar.extractall calls while preserving their
existing staging and cleanup behavior.
In `@website/public/index.html`:
- Around line 368-372: Version asynchronous editor work by a shared revision
counter. In website/public/index.html:368-372, increment the revision on every
Ketcher change before scheduling naming; in website/public/index.html:403-418,
capture the revision and validate it after getSmiles() before calling
requestName(); and in website/public/index.html:534-599, capture and validate
the revision before rendering explanation or depiction results. Ensure stale
completions cannot mutate the UI.
In `@website/requirements.txt`:
- Around line 1-2: Add an explicit h11>=0.16.0 constraint to
website/requirements.txt alongside the existing package specifications, while
leaving the openclatura and py2opsin entries unchanged.
---
Nitpick comments:
In `@src/openclatura/assembler.py`:
- Around line 148-233: The paren-aware _oxo_group_methyl_to_carbonyl helper
lacks regression coverage for nested heteroaryl-methyl groups. Add a focused
test for _oxo_group_methyl_to_carbonyl using
"(oxo)(1-((thiophen-2-yl)methyl)cyclohexyl)methyl" and assert it returns
"(1-((thiophen-2-yl)methyl)cyclohexyl)carbonyl".
In `@website/api/index.py`:
- Around line 140-186: Extract the shared AWS SigV4 canonical-request,
credential-scope, signing-key, and signature logic from _s3_request in
website/api/index.py (lines 140-186) into a small stdlib-only helper importable
by both files. Update website/scripts/cache_size.py (lines 30-63) to replace
s3_get’s duplicated signing implementation with that helper, retaining only its
GET query-string handling locally; update _s3_request to use the same helper as
well.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: e291da34-3cc0-4cbc-87b5-40a704b740c9
⛔ Files ignored due to path filters (7)
website/api/jre.tar.gzis excluded by!**/*.gzwebsite/api/xlibs.tar.gzis excluded by!**/*.gzwebsite/public/ketcher/apple-touch-icon.pngis excluded by!**/*.pngwebsite/public/ketcher/favicon-16x16.pngis excluded by!**/*.pngwebsite/public/ketcher/favicon-32x32.pngis excluded by!**/*.pngwebsite/public/ketcher/favicon.icois excluded by!**/*.icowebsite/public/ketcher/logo.svgis excluded by!**/*.svg
📒 Files selected for processing (48)
src/openclatura/assembler.pysrc/openclatura/assembly_parts.pysrc/openclatura/chains.pysrc/openclatura/charge_specs.pysrc/openclatura/component_modifiers.pysrc/openclatura/component_namer.pysrc/openclatura/data/parser_grammar_snapshot.jsonsrc/openclatura/engine.pysrc/openclatura/fused_ion_templates.pysrc/openclatura/heteroatom_subgraphs.pysrc/openclatura/ionic_naming.pysrc/openclatura/locants.pysrc/openclatura/molecule.pysrc/openclatura/name_assembly.pysrc/openclatura/name_operations.pysrc/openclatura/namer.pysrc/openclatura/naming_context.pysrc/openclatura/oxoacid_templates.pysrc/openclatura/perception.pysrc/openclatura/retained_fused_production.pysrc/openclatura/rules/elision.pysrc/openclatura/small_ring_stereo.pysrc/openclatura/special_cases.pysrc/openclatura/tests/test_analysis.pysrc/openclatura/von_baeyer.pytests/integration/test_describer.pywebsite/.gitignorewebsite/api/index.pywebsite/public/index.htmlwebsite/public/ketcher/asset-manifest.jsonwebsite/public/ketcher/index.htmlwebsite/public/ketcher/manifest.jsonwebsite/public/ketcher/robots.txtwebsite/public/ketcher/serve.jsonwebsite/public/ketcher/static/css/622.6d0c2d59.chunk.csswebsite/public/ketcher/static/css/closable.9cca8bc6.csswebsite/public/ketcher/static/css/duo.0ae0f354.csswebsite/public/ketcher/static/css/main.9cca8bc6.csswebsite/public/ketcher/static/css/popup.9cca8bc6.csswebsite/public/ketcher/static/js/157.7de4e426.chunk.jswebsite/public/ketcher/static/js/535.c5fc3b49.chunk.jswebsite/public/ketcher/static/js/535.c5fc3b49.chunk.js.LICENSE.txtwebsite/public/ketcher/static/js/622.ed91acd0.chunk.jswebsite/public/ketcher/static/js/main.cb80d824.jswebsite/public/ketcher/static/js/main.cb80d824.js.LICENSE.txtwebsite/requirements.txtwebsite/scripts/cache_size.pywebsite/vercel.json
💤 Files with no reviewable changes (9)
- src/openclatura/charge_specs.py
- src/openclatura/oxoacid_templates.py
- src/openclatura/rules/elision.py
- src/openclatura/name_operations.py
- src/openclatura/von_baeyer.py
- src/openclatura/ionic_naming.py
- src/openclatura/naming_context.py
- src/openclatura/fused_ion_templates.py
- src/openclatura/special_cases.py
| # Token spans map name characters back to atoms for the trace/describe output only; the | ||
| # final name string never depends on them. The engine disables their construction on the | ||
| # pure-name path (no trace requested), which skips ~10% of naming work. | ||
| _BUILD_TOKEN_SPANS = True | ||
|
|
||
|
|
||
| def set_token_span_building(enabled: bool) -> bool: | ||
| """Toggle token-span construction; returns the previous setting for restoration.""" | ||
|
|
||
| global _BUILD_TOKEN_SPANS | ||
| previous = _BUILD_TOKEN_SPANS | ||
| _BUILD_TOKEN_SPANS = enabled | ||
| return previous | ||
|
|
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n "ThreadPoolExecutor|threading\.Thread|run_in_threadpool|async def|WSGI|asgi" website/api/index.pyRepository: lamalab-org/openclatura
Length of output: 161
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Changed files/stat:"
git diff --stat || true
echo
echo "Relevant name_assembly lines:"
sed -n '1,90p' src/openclatura/name_assembly.py
sed -n '300,400p' src/openclatura/name_assembly.py
echo
echo "References to set_token_span_building / BUILD_TOKEN_SPANS:"
rg -n "set_token_span_building|_BUILD_TOKEN_SPANS|token_spans|need_analysis|trace" src website tests || true
echo
echo "Candidate API/entrypoint files:"
fd -i 'index\.py|.*api.*|.*main.*|.*app.*|.*service.*' . | sed -n '1,120p'Repository: lamalab-org/openclatura
Length of output: 50380
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Tracked files matching relevant names:"
git ls-files | rg -n 'src/openclatura/(name_assembly|engine)\.py$|website/api/index\.py$|tests/.*/api|api' || true
echo
echo "name_assembly outline:"
ast-grep outline src/openclatura/name_assembly.py 2>/dev/null | sed -n '1,160p' || true
echo
echo "name_assembly relevant sections:"
sed -n '1,80p' src/openclatura/name_assembly.py
sed -n '318,365p' src/openclatura/name_assembly.py
sed -n '376,388p' src/openclatura/name_assembly.py
echo
echo "engine.py run/save-restore references:"
rg -n "set_token_span_building|previous_span_building|NamingEngine|need_analysis|analysis|trace" src/openclatura src website tests -g '*.py' | sed -n '1,220p'Repository: lamalab-org/openclatura
Length of output: 33323
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "website/api/index.py outline:"
ast-grep outline website/api/index.py 2>/dev/null | sed -n '1,220p' || true
echo
echo "website/api/index.py:"
sed -n '1,260p' website/api/index.py
echo
echo "engine.py NamingEngine.run sections:"
sed -n '138,240p' src/openclatura/engine.py
echo
echo "Deterministic concurrency simulation of save/restore global toggle."
python3 - <<'PY'
# This models interleaved reads/writes of the module-level global used by
# NamingEngine.run(): the first request may not build spans because another
# request's toggle/cleanup writes over the global in between.
BUILD_TOKEN_SPANS_GLOBAL = True
events = []
def reader():
return BUILD_TOKEN_SPANS_GLOBAL
def writer(x):
global BUILD_TOKEN_SPANS_GLOBAL
BUILD_TOKEN_SPANS_GLOBAL = x
events.append(("global_write", x))
# Request A saves True and enables token spans.
writer(True)
previous_a = reader()
writer(True)
# Request B interrupts, saves True and disables token spans.
previous_b = reader()
writer(False)
# B needs span building here, but the global is now False.
events.append(("request_b_build_now", reader()))
# Request A finishes/cleanup; its restored value is the value it saved earlier,
# not another thread's save.
writer(previous_a)
events.append(("request_a_restore", reader()))
# Request B's cleanup also writes saved value onto the global.
writer(previous_b)
events.append(("request_b_restore", reader()))
for e in events:
print(f"{e[1]!r}")
PYRepository: lamalab-org/openclatura
Length of output: 16545
Make the span-building flag request-local in shared processes.
website/api/index.py already serializes /api/batch via _OPSIN_LOCK before calling NamingEngine(); batch(...), so trace-analysis requests can share a Python process and call NamingEngine.run() concurrently. The bare _BUILD_TOKEN_SPANS global can be overwritten mid-flight by a concurrent request, so one request may build token spans in non-request mode or omit an included analysis trace. Move the toggle into a thread/task-local request context and have the token-span path read that context instead of the module global.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/openclatura/name_assembly.py` around lines 15 - 28, The global
_BUILD_TOKEN_SPANS toggle in set_token_span_building is unsafe for concurrent
NamingEngine.run requests. Replace it with thread/task-local request context,
ensure set_token_span_building preserves and returns the caller’s prior context
value, and update the token-span construction path to read that context so each
request independently controls span building.
| if n_substituents: | ||
| sub_exclude = set(exclude_atoms) | {n_idx} | acid_atoms | ||
| sub_names = [str(name_subgraph(mol, s, sub_exclude, upstream_atom=n_idx)) for s in n_substituents] | ||
| if not all(sub_names): | ||
| return None | ||
| n_prefix = _format_n_locant_prefix(sub_names) | ||
| amido = f"{n_prefix}-{amido}" if amido[0].isdigit() else f"{n_prefix}{amido}" | ||
| return f"({amido})" if is_complex_prefix(amido) else amido |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Missing hyphen when the acid fragment's name starts with a parenthesized stereo descriptor.
The hyphen-insertion check only tests amido[0].isdigit(). When the acyl acid component's own name begins with a bracketed stereo descriptor (e.g. "(2S)-2-aminopropanoic acid" → amido = "(2S)-2-aminopropanamido"), this branch is skipped and the N-locant prefix is concatenated directly, producing a malformed name like "N-methyl(2S)-2-aminopropanamido" instead of "N-methyl-(2S)-2-aminopropanamido".
🐛 Proposed fix
- amido = f"{n_prefix}-{amido}" if amido[0].isdigit() else f"{n_prefix}{amido}"
+ amido = f"{n_prefix}-{amido}" if amido[0] in "(0123456789" else f"{n_prefix}{amido}"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if n_substituents: | |
| sub_exclude = set(exclude_atoms) | {n_idx} | acid_atoms | |
| sub_names = [str(name_subgraph(mol, s, sub_exclude, upstream_atom=n_idx)) for s in n_substituents] | |
| if not all(sub_names): | |
| return None | |
| n_prefix = _format_n_locant_prefix(sub_names) | |
| amido = f"{n_prefix}-{amido}" if amido[0].isdigit() else f"{n_prefix}{amido}" | |
| return f"({amido})" if is_complex_prefix(amido) else amido | |
| if n_substituents: | |
| sub_exclude = set(exclude_atoms) | {n_idx} | acid_atoms | |
| sub_names = [str(name_subgraph(mol, s, sub_exclude, upstream_atom=n_idx)) for s in n_substituents] | |
| if not all(sub_names): | |
| return None | |
| n_prefix = _format_n_locant_prefix(sub_names) | |
| amido = f"{n_prefix}-{amido}" if amido[0] in "(0123456789" else f"{n_prefix}{amido}" | |
| return f"({amido})" if is_complex_prefix(amido) else amido |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/openclatura/namer.py` around lines 1238 - 1245, Update the amido prefix
concatenation in the n_substituents branch to insert a hyphen when amido begins
with either a digit or a parenthesized stereo descriptor, while preserving the
existing no-hyphen behavior for other prefixes. Use the existing amido and
_format_n_locant_prefix logic without changing downstream complex-prefix
formatting.
| def _preload_xlibs() -> None: | ||
| if not _XLIBS_TARBALL.exists(): | ||
| return | ||
| if not (_XLIBS_DIR / _XLIBS_ORDER[-1]).exists(): | ||
| staging = Path(tempfile.mkdtemp(dir=tempfile.gettempdir())) | ||
| with tarfile.open(_XLIBS_TARBALL) as tar: | ||
| tar.extractall(staging) | ||
| try: | ||
| staging.rename(_XLIBS_DIR) | ||
| except OSError: # concurrent cold start already extracted it | ||
| shutil.rmtree(staging, ignore_errors=True) | ||
| for soname in _XLIBS_ORDER: | ||
| try: | ||
| ctypes.CDLL(str(_XLIBS_DIR / soname), mode=ctypes.RTLD_GLOBAL) | ||
| except OSError: | ||
| pass # already provided by the system, or depiction stays off | ||
|
|
||
|
|
||
| def _ensure_java() -> None: | ||
| if shutil.which("java"): | ||
| return | ||
| if not _JRE_TARBALL.exists(): | ||
| return | ||
| if not (_JRE_DIR / "bin" / "java").exists(): | ||
| staging = Path(tempfile.mkdtemp(dir=tempfile.gettempdir())) | ||
| with tarfile.open(_JRE_TARBALL) as tar: | ||
| tar.extractall(staging) | ||
| try: | ||
| staging.rename(_JRE_DIR) | ||
| except OSError: # concurrent cold start already extracted it | ||
| shutil.rmtree(staging, ignore_errors=True) | ||
| os.environ["PATH"] = f"{_JRE_DIR / 'bin'}{os.pathsep}{os.environ.get('PATH', '')}" | ||
| os.environ.setdefault("JAVA_HOME", str(_JRE_DIR)) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Harden tar.extractall() against path traversal (Zip Slip).
Both _preload_xlibs and _ensure_java call tar.extractall(staging) with no member-path validation. Actual exploitability is low here since jre.tar.gz/xlibs.tar.gz are bundled by your own build (not attacker-supplied), but it's a one-line hardening fix.
🔒 Proposed fix
with tarfile.open(_XLIBS_TARBALL) as tar:
- tar.extractall(staging)
+ tar.extractall(staging, filter="data")and similarly for the JRE extraction.
🧰 Tools
🪛 ast-grep (0.44.1)
[error] 63-63: Calling extractall() on a zipfile.ZipFile or tarfile archive without validating member paths lets a crafted entry (e.g. "../../etc/passwd") write outside the destination directory (Zip Slip). Validate each member resolves inside the target directory, or pass a safe filter (tarfile: filter="data" / tarfile.data_filter).
Context: tar.extractall(staging)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
(archive-extractall-path-traversal-python)
[error] 83-83: Calling extractall() on a zipfile.ZipFile or tarfile archive without validating member paths lets a crafted entry (e.g. "../../etc/passwd") write outside the destination directory (Zip Slip). Validate each member resolves inside the target directory, or pass a safe filter (tarfile: filter="data" / tarfile.data_filter).
Context: tar.extractall(staging)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
(archive-extractall-path-traversal-python)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@website/api/index.py` around lines 58 - 90, Harden both tar extraction sites
in _preload_xlibs and _ensure_java by validating archive member paths before
extraction, rejecting any entry that resolves outside the staging directory.
Apply the same safe-extraction approach to both tar.extractall calls while
preserving their existing staging and cleanup behavior.
Source: Linters/SAST tools
| # py2opsin writes a fixed-name temp file in the CWD, so concurrent | ||
| # requests in one warm process corrupt each other's OPSIN round-trip. | ||
| # Serialize the verify path (and retry once for residual flakes). | ||
| _OPSIN_LOCK = threading.Lock() | ||
|
|
||
|
|
||
| class _OpsinDaemon: | ||
| """Long-lived OPSIN CLI process, one per instance. | ||
|
|
||
| Spawning a JVM per verification costs ~1.5 s; OPSIN's CLI streams | ||
| names line-by-line (empty output line = unparseable), so one warm | ||
| JVM answers in milliseconds. Access is serialized by _OPSIN_LOCK. | ||
| On any protocol hiccup the process is killed and the caller falls | ||
| back to the one-shot py2opsin path. | ||
| """ | ||
|
|
||
| def __init__(self) -> None: | ||
| self._proc: subprocess.Popen | None = None | ||
|
|
||
| @staticmethod | ||
| def _jar_path() -> str | None: | ||
| try: | ||
| import py2opsin | ||
| except ImportError: | ||
| return None | ||
| jars = sorted(Path(py2opsin.__file__).parent.glob("opsin-cli-*.jar")) | ||
| return str(jars[-1]) if jars else None | ||
|
|
||
| def _ensure(self) -> bool: | ||
| if self._proc is not None and self._proc.poll() is None: | ||
| return True | ||
| jar = self._jar_path() | ||
| if jar is None or not shutil.which("java"): | ||
| return False | ||
| self._proc = subprocess.Popen( | ||
| ["java", "-jar", jar, "-osmi"], | ||
| stdin=subprocess.PIPE, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.DEVNULL, | ||
| text=True, | ||
| bufsize=1, | ||
| ) | ||
| return True | ||
|
|
||
| def parse(self, name: str, timeout: float = 15.0) -> str | None: | ||
| """SMILES for ``name``, "" if OPSIN can't parse it, None if the | ||
| daemon is unavailable (caller should fall back).""" | ||
| if "\n" in name or "\r" in name or not self._ensure(): | ||
| return None | ||
| proc = self._proc | ||
| try: | ||
| proc.stdin.write(name + "\n") | ||
| proc.stdin.flush() | ||
| ready, _, _ = select.select([proc.stdout], [], [], timeout) | ||
| if not ready: | ||
| raise TimeoutError(f"OPSIN gave no answer within {timeout}s") | ||
| line = proc.stdout.readline() | ||
| if line == "": | ||
| raise EOFError("OPSIN process closed stdout") | ||
| return line.rstrip("\n") | ||
| except Exception: | ||
| try: | ||
| proc.kill() | ||
| except Exception: | ||
| pass | ||
| self._proc = None | ||
| return None | ||
|
|
||
|
|
||
| _OPSIN_DAEMON = _OpsinDaemon() | ||
|
|
||
|
|
||
| def _opsin_check_via_daemon(name: str, smiles: str): | ||
| """Mirror openclatura.opsin_verify.verify_with_opsin, but decode the | ||
| name through the persistent JVM. Returns None to request fallback.""" | ||
| from openclatura.opsin_verify import OpsinCheck | ||
| from openclatura.resonance_compare import canonical_smiles, equivalent_smiles | ||
| from openclatura.utils import standardize_mol | ||
|
|
||
| if not name: | ||
| return OpsinCheck(status="name_empty", name=name) | ||
| decoded = _OPSIN_DAEMON.parse(name) | ||
| if decoded is None: | ||
| return None | ||
| canonical_original = standardize_mol(smiles) | ||
| if decoded == "": | ||
| return OpsinCheck(status="name_unparseable", name=name, canonical_original=canonical_original) | ||
| canonical_roundtrip = canonical_smiles(decoded) | ||
| if canonical_original is None or canonical_roundtrip is None: | ||
| return OpsinCheck( | ||
| status="error", | ||
| name=name, | ||
| canonical_original=canonical_original, | ||
| opsin_smiles=decoded, | ||
| canonical_roundtrip=canonical_roundtrip, | ||
| error_message="Failed to standardize SMILES for comparison.", | ||
| ) | ||
| return OpsinCheck( | ||
| status="matched" if equivalent_smiles(smiles, decoded) else "mismatched", | ||
| name=name, | ||
| canonical_original=canonical_original, | ||
| opsin_smiles=decoded, | ||
| canonical_roundtrip=canonical_roundtrip, | ||
| ) | ||
|
|
||
|
|
||
| class NameRequest(BaseModel): | ||
| smiles: str | ||
| include_trace: bool = False | ||
| verify_opsin: bool = False | ||
| token_debug: bool = False | ||
|
|
||
|
|
||
| def _name_cacheable(payload: dict, verify: bool) -> bool: | ||
| if not payload.get("ok"): | ||
| return False | ||
| if verify: | ||
| status = (payload.get("opsin_check") or {}).get("status") | ||
| return status in ("matched", "mismatched", "name_unparseable") | ||
| return True | ||
|
|
||
|
|
||
| @app.post("/api/name") | ||
| def name_endpoint(req: NameRequest) -> dict: | ||
| """Shadows the mounted app's /name: adds result caching and makes | ||
| OPSIN verification safe under in-process request concurrency.""" | ||
| flags = f"t{int(req.include_trace)}v{int(req.verify_opsin)}d{int(req.token_debug)}" | ||
| key = _cache_key("name", req.smiles, flags) | ||
| cached = _cache_get(key) | ||
| if cached is not None: | ||
| return cached | ||
| if not req.verify_opsin: | ||
| result = name_one(req.smiles, include_trace=req.include_trace, token_debug=req.token_debug) | ||
| else: | ||
| with _OPSIN_LOCK: | ||
| # include_trace=True mirrors the engine's verify branch, which | ||
| # always analyzes; to_dict() below trims it when not requested. | ||
| result = name_one(req.smiles, include_trace=True, token_debug=req.token_debug) | ||
| check = None if result.error else _opsin_check_via_daemon(result.name, req.smiles) | ||
| if check is not None: | ||
| result = dataclasses.replace(result, opsin_check=check) | ||
| else: | ||
| for attempt in (1, 2): | ||
| result = name_one( | ||
| req.smiles, | ||
| include_trace=req.include_trace, | ||
| verify_opsin=True, | ||
| token_debug=req.token_debug, | ||
| ) | ||
| if result.opsin_check is None or result.opsin_check.status != "error": | ||
| break | ||
| payload = result.to_dict(include_trace=req.include_trace) | ||
| if _name_cacheable(payload, req.verify_opsin): | ||
| _cache_set(key, payload) | ||
| return payload |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check whether the mounted app's /batch route performs OPSIN verification.
fd -e py . | xargs rg -nP -C3 '\bdef\s+batch\b|/batch|verify_opsin' 2>/dev/nullRepository: lamalab-org/openclatura
Length of output: 22071
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate opsin verify =="
fd -e py -a . | grep 'opsin_verify\.py$' || true
echo "== inspect source snippets =="
sed -n '1,220p' src/openclutura/opsin_verify.py 2>/dev/null || sed -n '1,260p' src/openclatura/opsin_verify.py 2>/dev/null || true
echo "== inspect engine one-shot verification line =="
sed -n '220,245p' src/openclatura/engine.py
echo "== inspect name_many/process pool =="
sed -n '245,285p' src/openclatura/engine.py
sed -n '423,445p' src/openclatura/engine.pyRepository: lamalab-org/openclatura
Length of output: 8921
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== imports of process pool and lock in api =="
sed -n '1,90p' website/api/index.py
sed -n '335,408p' website/api/index.py
echo "== exported web app and mounting =="
sed -n '1,130p' src/openclatura/web/app.py
echo "== read-only behavioral model of parallel py2opsin temp-file race =="
python3 - <<'PY'
import ast, re, sys
for path in ("src/openclatura/opsin_verify.py", "src/openclatura/engine.py"):
text = open(path, "r", encoding="utf-8").read()
tree = ast.parse(text, filename=path)
names = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
# call py2opsin.py2opsin?
cands = []
for sub in ast.walk(node):
if isinstance(sub, ast.Call):
call = getattr(sub.func, "id", "") if isinstance(sub.func, ast.Name) else None
if call == "py2opsin":
cands.append(sub)
if cands:
names.append(node.name)
print(path, names, "contains call to py2opsin.py2opsin:", any("py2opsin" in text for _ in cands))
print("processes default:", bool(re.search(r"processes: *int *=[^=\n]*= *(1)", open("src/openclatura/engine.py").read())))
print("parallel uses ProcessPoolExecutor:", bool(re.search(r"ProcessPoolExecutor", open("src/openclatura/engine.py").read())))
PYRepository: lamalab-org/openclatura
Length of output: 9451
Make OPSIN verification safe for parallel /api/batch.
name_many(..., processes != 1) runs verify_with_opsin(...) in worker processes via ProcessPoolExecutor, so enabled OPSIN verification can still invoke py2opsin concurrently. Either serialize OPSIN within verify_with_opsin/a shared daemon, or disable/offload OPSIN verification when batch runs with multiple processes.
🧰 Tools
🪛 ast-grep (0.44.1)
[error] 282-289: Command coming from incoming request
Context: subprocess.Popen(
["java", "-jar", jar, "-osmi"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(subprocess-from-request)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@website/api/index.py` around lines 249 - 403, Extend the OPSIN concurrency
protection beyond name_endpoint: ensure name_many batch execution with processes
!= 1 cannot invoke verify_with_opsin/py2opsin concurrently across worker
processes. Prefer routing verification through a shared serialized daemon, or
disable/offload verification for that multi-process path while preserving
existing behavior for single-process and non-batch requests.
| class DescribeRequest(BaseModel): | ||
| smiles: str | ||
|
|
||
|
|
||
| @app.post("/api/describe") | ||
| def describe_endpoint(req: DescribeRequest) -> dict: | ||
| """Shadows the mounted app's /describe to add result caching.""" | ||
| key = _cache_key("desc", req.smiles) | ||
| cached = _cache_get(key) | ||
| if cached is not None: | ||
| return cached | ||
| payload = describe(req.smiles).to_dict() | ||
| _cache_set(key, payload) | ||
| return payload |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
describe_endpoint has no error handling for unparsable SMILES.
Unlike depict (which explicitly returns {"ok": False, "error": ...} for bad SMILES), describe_endpoint calls describe(req.smiles) directly. If describe() raises rather than returning a structured error for invalid input, callers get a generic 500 instead of a consistent API error shape.
🛡️ Proposed defensive fix
`@app.post`("/api/describe")
def describe_endpoint(req: DescribeRequest) -> dict:
"""Shadows the mounted app's /describe to add result caching."""
key = _cache_key("desc", req.smiles)
cached = _cache_get(key)
if cached is not None:
return cached
- payload = describe(req.smiles).to_dict()
+ try:
+ payload = describe(req.smiles).to_dict()
+ except Exception as exc:
+ return {"ok": False, "error": str(exc)}
_cache_set(key, payload)
return payload📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| class DescribeRequest(BaseModel): | |
| smiles: str | |
| @app.post("/api/describe") | |
| def describe_endpoint(req: DescribeRequest) -> dict: | |
| """Shadows the mounted app's /describe to add result caching.""" | |
| key = _cache_key("desc", req.smiles) | |
| cached = _cache_get(key) | |
| if cached is not None: | |
| return cached | |
| payload = describe(req.smiles).to_dict() | |
| _cache_set(key, payload) | |
| return payload | |
| class DescribeRequest(BaseModel): | |
| smiles: str | |
| `@app.post`("/api/describe") | |
| def describe_endpoint(req: DescribeRequest) -> dict: | |
| """Shadows the mounted app's /describe to add result caching.""" | |
| key = _cache_key("desc", req.smiles) | |
| cached = _cache_get(key) | |
| if cached is not None: | |
| return cached | |
| try: | |
| payload = describe(req.smiles).to_dict() | |
| except Exception as exc: | |
| return {"ok": False, "error": str(exc)} | |
| _cache_set(key, payload) | |
| return payload |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@website/api/index.py` around lines 406 - 419, Update describe_endpoint to
handle exceptions raised by describe(req.smiles), returning the same {"ok":
False, "error": ...} structured response used by depict for unparsable SMILES
instead of propagating a generic 500. Preserve the existing cache lookup and
successful-response caching behavior.
| ketcher.editor.subscribe("change", function () { | ||
| clearTimeout(debounceTimer); | ||
| clearTimeout(failureTimer); // still drawing: a pending failure is moot | ||
| debounceTimer = setTimeout(nameCurrent, EDIT_DEBOUNCE_MS); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Version asynchronous work by editor revision. Increment a revision on every Ketcher change and require asynchronous completions to match it before mutating the UI.
website/public/index.html#L368-L372: increment a shared editor revision before scheduling naming.website/public/index.html#L403-L418: capture and validate that revision aroundgetSmiles()before callingrequestName().website/public/index.html#L534-L599: capture and validate the revision before rendering explanation or depiction results.
🧰 Tools
🪛 ast-grep (0.44.1)
[error] 370-370: React's useState should not be directly called
Context: setTimeout(nameCurrent, EDIT_DEBOUNCE_MS)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.
(usestate-direct-usage)
[warning] 370-370: Avoid using the initial state variable in setState
Context: setTimeout(nameCurrent, EDIT_DEBOUNCE_MS)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.
(setstate-same-var)
📍 Affects 1 file
website/public/index.html#L368-L372(this comment)website/public/index.html#L403-L418website/public/index.html#L534-L599
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@website/public/index.html` around lines 368 - 372, Version asynchronous
editor work by a shared revision counter. In website/public/index.html:368-372,
increment the revision on every Ketcher change before scheduling naming; in
website/public/index.html:403-418, capture the revision and validate it after
getSmiles() before calling requestName(); and in
website/public/index.html:534-599, capture and validate the revision before
rendering explanation or depiction results. Ensure stale completions cannot
mutate the UI.
| openclatura[web]==0.1.4 | ||
| py2opsin>=1.2 |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Confirm which installed package pulls in the vulnerable pillow/h11 versions.
pip download openclatura[web]==0.1.4 --no-deps -d /tmp/ocw 2>&1 | tail -5
pip show openclatura 2>/dev/nullRepository: lamalab-org/openclatura
Length of output: 331
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== requirements files =="
fd -a 'requirements.*txt$|pyproject.toml$|setup.py$|setup.cfg$' . | sed 's#^\./##'
echo
echo "== website/requirements.txt =="
cat -n website/requirements.txt
echo
echo "== package metadata snippets =="
python3 - <<'PY'
import zipfile
from pathlib import Path
p = next(Path("/tmp/ocw").glob("openclatura-0.1.4*.whl"))
with zipfile.ZipFile(p) as z:
names = [n for n in z.namelist() if n.endswith(('.dist-info/METADATA','.egg-info/SOURCES.txt','.egg-info/requires.txt')) or n.endswith(('.egg-info/top_level.txt','.egg-info/entry_points.txt'))]
for name in sorted(names):
print(f"\n--- {name} ---")
try:
txt = z.read(name).decode('utf-8', errors='replace')
except Exception as e:
print(type(e).__name__, e)
continue
for line in txt.splitlines()[:80]:
if any(s in line.lower() for s in ['pillow','h11','requires','requires-python','project-url','home-page']):
print(line)
PY
echo
echo "== dependency resolver snapshot for transitive deps =="
python3 - <<'PY'
import subprocess, sys, tempfile, os, pathlib, re
req = pathlib.Path("website/requirements.txt").read_text().strip().splitlines()
print(req)
with tempfile.TemporaryDirectory() as d:
r = pathlib.Path(d) / "requirements.txt"
r.write_text("\n".join(req))
try:
out = subprocess.check_output([sys.executable, "-m", "pip", "install", "--dry-run", "--ignore-installed", "--no-index", "--find-links=/tmp/ocw", "-r", str(r)], stderr=subprocess.STDOUT, text=True, timeout=120)
except subprocess.CalledProcessError as e:
print("STDOUT:")
print(e.stdout.splitlines()[-40:] if e.stdout else "")
print("STDERR:")
print(e.stderr.splitlines()[-80:] if e.stderr else "")
raise SystemExit(0)
lines = out.splitlines()
for i,l in enumerate(lines):
if l.lower().startswith("would install"):
for j in range(i, min(i+200, len(lines))):
print(lines[j])
break
PY
echo
echo "== installed pillow/h11 in sandbox? =="
pip show pillow h11 2>/dev/null || true
echo
echo "== search for local config constraints or OSV config =="
rg -n "h11|pillow|openclatura|py2opsin|osv|scanning|requirements" . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' 2>/dev/null | head -200Repository: lamalab-org/openclatura
Length of output: 40986
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== inspect web dependency metadata =="
python3 - <<'PY'
import zipfile
from pathlib import Path
for whl in sorted(Path("/tmp/ocw").glob("*.whl")):
if "openclatura" not in whl.name:
continue
print(f"\n=== {whl.name} ===")
with zipfile.ZipFile(whl) as z:
candidates = [n for n in z.namelist() if n.endswith(".dist-info/METADATA") or n.endswith(".egg-info/requires.txt")]
for cand in candidates:
txt = z.read(cand).decode("utf-8", errors="replace")
print(f"\n-- METADATA/requires at {cand} --")
for line in txt.splitlines():
if "web" in line.lower() or "pillow" in line.lower() or "h11" in line.lower() or line.startswith("Requires-Dist:") or line.startswith("Requires-Python:"):
print(line)
PYRepository: lamalab-org/openclatura
Length of output: 1380
🌐 Web query:
uvicorn standard extra pillow h11 dependencies
💡 Result:
uvicorn[standard] installs: uvloop, httptools, websockets, watchfiles, python-dotenv, and PyYAML. The base uvicorn install also includes h11 and click (and typing_extensions on Python 3.10/earlier). Pillow is not part of Uvicorn’s dependencies. (uvicorn.dev)
Citations:
🌐 Web query:
OSV Pillow 9.5.0 CVE RCE DoS buffer overflow
💡 Result:
Pillow 9.5.0 is affected by CVE-2024-28219, a buffer overflow in _imagingcms.c caused by strcpy being used instead of strncpy. OSV lists 9.5.0 among affected versions, and the issue is fixed in Pillow 10.3.0. It can lead to denial of service and potentially arbitrary code execution (RCE). [1][2][3]
If you want, I can also give you the exact OSV entry and a one-line remediation for package manifests.
🌐 Web query:
OSV H11 0.9.0 malformed chunked encoding CVE
💡 Result:
The vulnerability regarding malformed chunked encoding in the h11 Python HTTP/1.1 library is tracked as CVE-2025-43859 [1][2]. Details: The vulnerability arises from a leniency in h11's parsing of line terminators in chunked-coding message bodies [3][1]. Specifically, prior to version 0.16.0, h11 did not strictly validate that the trailing \r\n bytes in chunked-encoding chunks were correct [3][4]. If malformed bytes were provided, h11 would accept them instead of rejecting the body as malformed [1][4]. This behavior can lead to HTTP request smuggling vulnerabilities if an h11-based application is placed behind an intermediary (such as a proxy or reverse-proxy) that parses the same chunked-encoding stream differently [3][1]. If both systems accept the same stream but interpret it as different request structures, an attacker may be able to smuggle requests to the backend [3][4]. Mitigation: This issue was fixed in h11 version 0.16.0 [1][2]. Upgrading to version 0.16.0 or later is the recommended way to resolve the vulnerability [2][5]. Because the vulnerability requires a combination of a buggy h11 implementation and a buggy intermediary, fixing either component is sufficient to mitigate the risk [2][6]. The vulnerability is categorized under CWE-444: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling') [1][6].
Citations:
- 1: GHSA-vqfr-h8mv-ghfj
- 2: https://osv.dev/vulnerability/CVE-2025-43859
- 3: https://osv.dev/vulnerability/GHSA-vqfr-h8mv-ghfj
- 4: https://osv.dev/GHSA-vqfr-h8mv-ghfj
- 5: https://nvd.nist.gov/vuln/detail/CVE-2025-43859
- 6: https://nvd.nist.gov/vuln/detail/cve-2025-43859
🌐 Web query:
fastapi uvicorn standard dependencies pillow h11
💡 Result:
If you’re asking what these mean together:
- FastAPI: web framework for APIs. Its
fastapi[standard]install pulls in recommended extras, including Uvicorn. (pypi.org) - Uvicorn: ASGI server.
uvicorninstalls minimal deps including h11 andclick;uvicorn[standard]adds extra performance/recommended packages likeuvloop. (uvicorn.org) - h11: pure-Python HTTP/1.1 implementation used by Uvicorn. (uvicorn.org)
- Pillow: image-processing library; it’s unrelated to FastAPI/Uvicorn and is not part of their standard dependencies. (pypi.org)
So, in short: FastAPI → Uvicorn → h11 is the web stack; Pillow is separate. (pypi.org)
Citations:
- 1: https://pypi.org/project/fastapi/?utm_source=openai
- 2: https://www.uvicorn.org/installation/?utm_source=openai
- 3: https://www.uvicorn.org/installation/?utm_source=openai
- 4: https://pypi.org/project/pillow/?utm_source=openai
- 5: https://pypi.org/project/fastapi/?utm_source=openai
🌐 Web query:
py2opsin dependencies Pillow
💡 Result:
py2opsin has zero Python dependencies, so Pillow is not required for it. The package bundles OPSIN itself. (pypi.org)
Citations:
🌐 Web query:
fastapi pydantic pillow dependency
💡 Result:
FastAPI depends on Pydantic and Starlette; Pillow is not a FastAPI/Pydantic dependency. If you need image handling, install Pillow separately (pip install pillow). (fastapi.tiangolo.com)
If you want, I can also give you the exact pip/uv install line for a FastAPI + Pydantic + Pillow project.
Citations:
Pin the vulnerable h11 transitive dependency.
openclatura[web] brings in uvicorn, whose base dependency tree includes h11. h11 versions prior to 0.16.0 are affected by CVE-2025-43859; add an explicit constraint here such as h11>=0.16.0. pillow/PyYAML are not dependencies of this requirements file’s direct package specs, so this file does not need a Pillow override.
🧰 Tools
🪛 OSV Scanner (2.4.0)
[CRITICAL] 1-1: h11 0.9.0: h11 accepts some malformed Chunked-Encoding bodies
(PYSEC-2026-348)
[CRITICAL] 1-1: h11 0.9.0: h11 accepts some malformed Chunked-Encoding bodies
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2023-175)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2023-227)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-165)
[CRITICAL] 1-1: pillow 9.5.0: Pillow buffer overflow vulnerability
(PYSEC-2026-1793)
[CRITICAL] 1-1: pillow 9.5.0: libwebp: OOB write in BuildHuffmanTable
(PYSEC-2026-1794)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-2253)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-2254)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-2255)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-2256)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-2257)
[CRITICAL] 1-1: pillow 9.5.0: Pillow has a PDF Parsing Trailer Infinite Loop (DoS)
(PYSEC-2026-2874)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-3451)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-3453)
[CRITICAL] 1-1: pillow 9.5.0: undefined
(PYSEC-2026-3454)
[CRITICAL] 1-1: pillow 9.5.0: Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)
(PYSEC-2026-3493)
[CRITICAL] 1-1: pillow 9.5.0: Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images
(PYSEC-2026-3494)
[CRITICAL] 1-1: pillow 9.5.0: Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()
(PYSEC-2026-3495)
[CRITICAL] 1-1: pillow 9.5.0: Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service
(PYSEC-2026-3496)
[CRITICAL] 1-1: pillow 9.5.0: Arbitrary Code Execution in Pillow
(PYSEC-2026-457)
[CRITICAL] 1-1: pillow 9.5.0: Arbitrary Code Execution in Pillow
[CRITICAL] 1-1: pillow 9.5.0: Pillow buffer overflow vulnerability
[CRITICAL] 1-1: pillow 9.5.0: Pillow BdfFontFile: Image.new() called without _decompression_bomb_check() — bomb protection bypass via font loading
[CRITICAL] 1-1: pillow 9.5.0: Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path
[CRITICAL] 1-1: pillow 9.5.0: Pillow: FontFile.compile(): Image.new() called without _decompression_bomb_check()
[CRITICAL] 1-1: pillow 9.5.0: Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)
[CRITICAL] 1-1: pillow 9.5.0: Pillow: Heap out-of-bounds write Image.paste() / Image.crop() via signed coordinate overflow
[CRITICAL] 1-1: pillow 9.5.0: Pillow Denial of Service vulnerability
[CRITICAL] 1-1: pillow 9.5.0: Pillow PcfFontFile._load_bitmaps(): Image.frombytes() called without _decompression_bomb_check() — bomb protection bypass via PCF font loading
[CRITICAL] 1-1: pillow 9.5.0: Pillow: Controlled heap out-of-bounds write in Pillow ImageCmsTransform.apply() via output mode mismatch
[CRITICAL] 1-1: pillow 9.5.0: Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images
[CRITICAL] 1-1: pillow 9.5.0: libwebp: OOB write in BuildHuffmanTable
[CRITICAL] 1-1: pillow 9.5.0: Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()
[CRITICAL] 1-1: pillow 9.5.0: Pillow GdImageFile._open(): image dimensions accepted without _decompression_bomb_check()
[CRITICAL] 1-1: pillow 9.5.0: Pillow has a PDF Parsing Trailer Infinite Loop (DoS)
[CRITICAL] 1-1: pillow 9.5.0: Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service
[CRITICAL] 1-1: pillow 9.5.0: Pillow has an integer overflow when processing fonts
[CRITICAL] 1-1: pillow 9.5.0: Pillow: Heap out-of-bounds write in ImageFilter.RankFilter via integer overflow in ImagingExpand
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@website/requirements.txt` around lines 1 - 2, Add an explicit h11>=0.16.0
constraint to website/requirements.txt alongside the existing package
specifications, while leaving the openclatura and py2opsin entries unchanged.
Source: Linters/SAST tools
Summary by CodeRabbit
New Features
Improvements
Bug Fixes