Cello v1.2.1 — Critical Bug Fixes¶
Release Date: June 14, 2026 License: MIT Python: 3.12+
Overview¶
Cello v1.2.1 is a patch release fixing critical bugs discovered after v1.2.0: the HTTP server never bound its port, ProblemDetails was inaccessible from Python, and And/Or guards rejected the And(g1, g2) call style. All changes are fully backwards-compatible.
Bug Fixes¶
Server Port Never Bound¶
Symptom: app.run() started without error but the server never listened on any port — ss -tlnp showed nothing, all connections were refused.
Root cause: pyo3_asyncio::tokio::run drives Tokio socket I/O through Python's asyncio selector loop. In Python 3.12+ with pyo3 0.20, the two event loops do not integrate correctly: the asyncio loop never ticks the Tokio I/O reactor, so TcpListener::accept() never resolves and the port never appears in the OS socket table.
Fix: Replaced pyo3_asyncio::tokio::run(py, ...) with py.allow_threads(|| runtime.block_on(...)). The GIL is released for the duration of the server run; Tokio drives all I/O natively. Python handlers re-acquire the GIL individually via Python::with_gil when invoked.
# Before (broken — port never bound)
app.run(port=8000) # silently hangs
# After (fixed)
app.run(port=8000) # binds and serves immediately
ProblemDetails Not Importable¶
Symptom: from cello import ProblemDetails raised ImportError.
Root cause: ProblemDetails had #[pyclass] in src/error.rs but was never registered with m.add_class::<error::ProblemDetails>() in the PyO3 module, and was missing from python/cello/__init__.py.
Fix: Added to module registration and Python exports.
# Now works
from cello import ProblemDetails
@app.exception_handler(ValueError)
def handle_error(request, exc):
return ProblemDetails(
type_uri="/errors/validation",
title="Validation Error",
status=400,
detail=str(exc),
instance=request.path,
).to_response()
Field name is type_uri, not type_url
The constructor parameter follows the RFC 7807 naming convention. Use type_uri=, not type_url=.
And/Or Guards Rejected *args Call Style¶
Symptom: And(guard1, guard2) raised TypeError: And.__init__() takes 2 positional arguments but 3 were given.
Root cause: And and Or only accepted a single list argument.
Fix: Both now accept either a list or *args.
# Both styles now work
admin_and_write = And([RoleGuard(["admin"]), PermissionGuard(["write"])])
admin_and_write = And(RoleGuard(["admin"]), PermissionGuard(["write"]))
admin_or_editor = Or([RoleGuard(["admin"]), RoleGuard(["editor"])])
admin_or_editor = Or(RoleGuard(["admin"]), RoleGuard(["editor"]))
CSRF Cookie Readable by JavaScript (Security)¶
Symptom: AJAX/SPA applications using CSRF protection failed all POST/PUT/DELETE requests with 403.
Root cause: The CSRF double-submit cookie was set with the HttpOnly flag. This prevents JavaScript from reading the cookie value, making it impossible for the frontend to copy it into the X-CSRF-Token header — which is the entire point of the double-submit pattern.
Fix: Removed HttpOnly from the CSRF cookie. The CSRF cookie is intentionally readable by JavaScript; the session cookie (where sensitive data lives) remains HttpOnly.
// Now works — JS can read the CSRF cookie and send it back
const csrf = document.cookie.match(/_csrf=([^;]+)/)?.[1];
fetch("/api/data", {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: JSON.stringify(data),
});
Rate Limiter Fixed-Window Counter Reset (Bug)¶
Symptom: After the first rate-limit window expired, the fixed-window counter reset to zero on every single subsequent request, making rate limiting completely ineffective.
Root cause: When the time window rolled over, entry.count was reset to 0 but entry.window_start was never updated to the new window. Every request in the new window saw window_start != current_window and reset the counter again.
Fix: Update entry.window_start = current_window alongside the count reset.
Auth Skip-Path Prefix Bypass (Security)¶
Symptom: Calling skip_path("/health") to exclude the health endpoint from authentication also excluded /healthz, /healthy, and any other path starting with /health.
Root cause: Path matching used request.path.starts_with(skip_path) without checking that the following character is / or end-of-string.
Fix: Path is now only skipped when it equals the pattern exactly or starts with {pattern}/. Affects JwtAuth, BasicAuth, ApiKeyAuth, RateLimitMiddleware, SessionMiddleware, CsrfMiddleware, and GuardsMiddleware.
# Before: /healthz would also be skipped (security bypass)
jwt.skip_path("/health")
# After: only /health and /health/* are skipped
jwt.skip_path("/health")
Upgrade¶
No API changes — drop-in replacement for v1.2.0.