Releases: sillohq/core
Release list
sillo-core-v0.3.1
[0.3.1] - 2026-08-27
A test-coverage push that took the suite from 91.58% to over 95%. Each of the
fixes below was found the same way: writing the first test that actually
reached the code.
Fixed
-
CORS
custom_error_messageswas silently discarded.CorsConfig.__init__
built its settings dict with the argument's slot hardcoded toNone
regardless of what was passed in, so every custom error message a project
configured was replaced with the generic"CORS request denied."default. -
send_email(attachments=[...])crashed on anEmailAttachmentobject.
Passing a dict worked; passing an already-builtEmailAttachmentdid not —
the non-dict branch calledmessage.add_attachment(att), treating the
whole attachment object as if it were a filename string, which raised
TypeError: missing 1 required positional argument: 'content'on every
call. -
BackgroundTask.drain()could report a negativecompletedcount.
asyncio.wait()already returnsdoneandpendingas disjoint sets, but
drain()subtracted the cancelled count fromlen(done)a second time,
so cancelling any pending task on timeout sentcompletednegative. -
A
SupervisoronRestartPolicy.ALWAYS(orEXPONENTIAL_BACKOFF)
restarted a successful task forever.max_restartswas only checked on
the failure path; a supervised task that kept succeeding was relaunched
immediately, with nothing incrementing the restart counter or checking the
limit, in a busy loop with no way to stop it short ofstop(). -
ListenerRegistry.once()never actually unsubscribed a typed-event
listener. It registered a wrapper closure with the dispatcher, then
calleddispatcher.forget(event, callback)with the original callback —
forget()matches by identity, so it never found the wrapper it needed to
remove, and the listener kept firing on every dispatch instead of just the
first. -
The sync
@cache()decorator deadlocked when called from inside a
running event loop. The "run on a private loop in a thread" comment
described what should happen; the code scheduled the lookup coroutine onto
a brand-newasyncioloop viarun_coroutine_threadsafewithout ever
starting that loop running anywhere, sofut.result()waited on a
computation that could never occur. The private loop now actually runs in
a background thread for the duration of the call.
Full Changelog: sillo-core-v0.3.0...sillo-core-v0.3.1
sillo-core-v0.3.0
[0.3.0] - 2026-08-21
Added
sillo.storage.current_storage()andsillo.storage.bucket(name)— reach theStoragesetup_storageregistered, from a handler, a queue job, or a script, without the application object or a request.sillo.mail.current_mail()andsillo.mail.send_email(...)— same shape, for theMailClientsetup_mailregistered.sillo._internals.registry.InstanceRegistry, the shared internal mechanism behind both: a plain slot filled once at startup and read from anywhere, raisingNotConfiguredError— notNone— when asked beforesetup_xhas run. Documented in Instance Registry.useAuth(unauthorized=..., forbidden=...)— per-route hooks that answer a failed gate directly instead of raisingAuthenticationFailed/PermissionDeniedfor the global exception handler to catch. Each is called ashook(request, response), sync or async, and its return value is sent as the response with the route handler never reached. Unset, a gate behaves exactly as before.
sillo-core-v0.2.1
[0.2.1] - 2026-08-19
Changed
- Replaced
itsdangerouswith an internalsillo.helpers.signingimplementation for CSRF tokens and signed session cookies. - Moved
sillo.utilsmodules intosillo.helpers. - Fixed
typing-extensionsdependency marker to require the package on Python < 3.13.
sillo-core-v0.2.0
What's Changed
- feat(env): read .env in the framework, and drop python-dotenv by @TechWithDunamix in #423
- fix(routing): decide method-not-allowed once, and make Allow honest by @TechWithDunamix in #424
Full Changelog: sillo-core-v0.1.0...sillo-core-v0.2.0
sillo-core-v0.1.0
Sillo 0.1.0
Released 2026-08-17 · sillo-framework on PyPI · imports as sillo · BSD-3-Clause
The first stable release. From here the public API is covered by semantic
versioning: anything documented is something a 0.1.x release will not break, and
every deprecation names the version that removes it rather than pointing at "a
future release".
These notes cover the whole 0.1.0 line — 0.1.0b1, b2, b3 and the final —
because the last widely-installed version before it was 0.0.2a3. If you are
already on a beta, only the New in the final release and Deprecated
sections are new to you.
uv add sillo-frameworksillo is a different project on PyPI. The distribution is sillo-framework;
the import is sillo.
Read this first if you are upgrading from an alpha
This line carries eight security fixes, ten breaking changes, and a
change to how pip and uv resolve the package. Two of the security findings
chain into unauthenticated account takeover for any application using the file
session backend. That backend is not the default, so an application on the
signed-cookie default was never exposed to those two — but read the Security
section before deciding this upgrade can wait.
Pre-releases are normally skipped unless asked for. That rule only applies when
there is a stable release to prefer, and until today sillo-framework had never
published one — so uv add sillo-framework was already resolving 0.1.0b1
rather than the last alpha. It now resolves 0.1.0.
Security
Five findings were reported privately on 2026-08-12. One more was found while
doing the middleware-chain work below, and two came out of the model and hashing
audit in b2. All eight are fixed.
A session cookie could read and write any file on the machine
FileSessionManager joined the cookie's value straight into a path, so
session_id=../../../../etc/cron.d/x addressed a file outside the session
directory — arbitrary read through the load path, arbitrary write through the
save path, from one request, with no authentication. os.path.join made it
worse than ordinary traversal: an absolute value discarded the configured
directory altogether, so /root/.ssh/authorized_keys needed no ../ at all.
A key must now match [A-Za-z0-9_-]{1,128} — wider than the 64 hex characters
generate_session_key produces, so a project that overrides the generator keeps
working, and narrow enough to exclude every character that can address another
directory. A cookie that fails is treated as though none was sent, so the
response cannot be used to probe the filesystem. _get_file_path additionally
resolves the result and refuses anything that leaves the store, which covers a
symlinked storage directory among other things the pattern did not anticipate.
Logging in did not change the session identifier
login() wrote the user into the session and nothing else, so an identifier
known before authentication was still valid after it. An attacker who fixed a
session key in a victim's browser held an authenticated session the moment the
victim signed in, without stealing a cookie. Session.cycle_key() is new and
login() calls it.
allow_origins=["*"] returned Access-Control-Allow-Credentials: true to every caller
allow_credentials defaulted to True, and a wildcard was answered by
reflecting the caller's own Origin rather than sending *. Browsers reject a
literal * on a credentialed request, and reflecting is precisely what evades
that check — so any site could read responses authenticated as your users. This
was the configuration our own docstrings showed.
allow_credentials now defaults to False, and combining it with a wildcard
raises at construction rather than being quietly downgraded.
update_from_dict() wrote any field named to it
Handed a request body it would set any column, including the ones deciding what
a user may do. Models may now declare fillable or guarded, and a single call
may pass only=. A model that states none of them behaves exactly as before.
The encrypted cast was not encryption
It was XOR against a repeating key, with a source comment reading "Simple XOR +
base64 for demo" — so a column named encrypted held recoverable plaintext. It
is now Fernet (AES-128-CBC with an HMAC-SHA256 tag), keyed by PBKDF2-HMAC-SHA256
over the passphrase, and needs the new crypto extra.
Values written by the old caster cannot be read by this one. They were not
protected in the first place, so rewrite them from plaintext.
PasswordField double-hashed non-bcrypt hashes
Its already-hashed check matched the bcrypt prefixes alone, so an argon2, scrypt
or pbkdf2 hash assigned to the field was hashed a second time and never verified
again. It now asks passlib which scheme produced the value.
sillo.hashing.is_hashed is new for the same question elsewhere.
A debug error page could render another request's headers
ServerErrorMiddleware stored the request on itself and read it back while
rendering. One instance serves every request, so the attribute held whichever
request wrote to it last — and a request that failed slowly returned a
concurrent request's Cookie and Authorization to the wrong client. It needed
Accept: text/html, which is to say a browser, and debug is on by default.
The attribute is gone rather than synchronised: a middleware instance is shared,
so per-request state does not belong on it at all.
A server-side session was never deleted
Session.save() cleared deleted before handing the session to the backend, so
logging out overwrote the file with {} instead of removing it. No data
survived, so this was not exploitable — but the delete path could not be tested
and session files accumulated forever.
Breaking changes
| Change | What to do |
|---|---|
CorsConfig(allow_credentials=...) defaults to False |
Set it explicitly if you relied on the old default. It can no longer be paired with "*". |
logout() empties the whole session |
It no longer removes only the entry session_key names. The argument is still accepted and now selects nothing. |
encrypted cast values are unreadable |
Rewrite the columns from plaintext, and install the crypto extra. |
sillo.record.rollback is rollback_migrations |
The migration command and the transaction helper shared a name; the package re-exported whichever was imported last. |
Reading a request body twice raises RuntimeError: Stream consumed |
Previously it hung until the client gave up. A body that was never read is still available to the handler. |
app.use(middleware, *args) without raw=True raises TypeError |
The dispatch form takes an already-configured middleware; extra arguments were being silently dropped. |
SilloApp.debug is a property |
SilloApp(debug=...) is unchanged. The instance attribute is now _debug. |
ServerErrorMiddleware.generate_html(exc, request, limit=7) |
Pass the request. A caller with the old shape fails with a TypeError at the call rather than rendering an unrelated request. |
ServerErrorMiddleware(app, handler=..., debug=...) |
The next ASGI app is the first positional parameter. Keyword calls are unaffected. |
ServerErrorMiddleware and ExceptionMiddleware are raw ASGI |
Neither can be passed to app.use() in its dispatch form. Neither is exported from sillo or listed in __all__. |
Everything an application normally touches is unchanged: app.use() with
dispatch functions and BaseMiddleware subclasses, add_exception_handler for
both exception classes and status codes, handler signatures,
server_error_handler, debug tracebacks, 404s, and validation errors.
Performance
The two built-in error layers are now raw ASGI middleware. Both were dispatch
middleware wrapped in a bridge that built a Request, a Response, an
anyio.Event, a memory object stream and a background task on every request —
and neither ever wanted any of it, since both only look at a request that
raised. They now construct a request and a response only inside their except
clause.
Framework overhead, measured in-process through the ASGI interface:
| case | before | after | FastAPI 0.141.1 |
|---|---|---|---|
| plain text | 702.8µs | 27.1µs | 48.4µs |
| small JSON | 730.2µs | 37.5µs | 58.9µs |
| int path param | 821.8µs | 38.0µs | 75.1µs |
| 200-row JSON | 1679.7µs | 826.0µs | 2279.3µs |
The router was never the bottleneck — it matched at ~20µs on its own. There is
no server, socket or database in those numbers; a handler doing a 2ms query is
2ms either way.
The middleware chain is also assembled once now, in __init__, rather than
being rebuilt per request. That is worth 1–2% on a request, which is not why it
is worth doing — it stops the per-request garbage. It is also what turned
ServerErrorMiddleware into a shared instance, which is how the header leak
above was found.
Against other frameworks
A benchmark suite ships in benchmarks/, run against FastAPI, Starlette, Django
and Flask through their ASGI interfaces in-process. Throughput in requests per
second, median of the measured rounds, on an Intel i9-9980HK under Python 3.14.6
with oha 1.15.0:
| scenario | sillo | fastapi | starlette | django | flask |
|---|---|---|---|---|---|
| plaintext | 4,531 | 3,558 | 4,923 | 1,125 | 1,413 |
| json | 3,666 | 3,411 | 4,848 | 1,124 | 1,318 |
| path-param | 3,774 | 3,214 | 4,367 | 893 | 1,249 |
| query-param | 2,874 | 2,817 | 4,559 | 896 | 1,271 |
| rows | 1,931 | 2,247 | 2,417 | 602 | 970 |
Starlette is ahead on every scenario and FastAPI is ahead on rows. Those are
published because they are the numbers the suite produced; query-param and
`...
sillo-core-v0.1.0b1
[0.1.0b1] - 2026-08-13
The first beta. The version moves off the 0.0.2 line because five of the
changes below are breaking, and because a release carrying six security fixes
should not look like a third patch.
Being a pre-release, uv add sillo-framework still resolves the last alpha.
Install it deliberately:
uv add "sillo-framework==0.1.0b1"
uv add sillo-framework --prerelease=allowEvery finding under Security was reported privately and is fixed here. Three
of them were features that had never worked at all rather than edge cases:
generate_ulid() called an API that does not exist in the ULID package this
project depends on, a 304 response shipped the whole body behind
Content-Length: 0, and register_transport() could never resolve a backend
it had registered.
Security
Five findings, reported privately on 2026-08-12 and fixed here. The first two
chain into unauthenticated account takeover for any application using the file
session backend; that backend is not the default, so an application on the
signed-cookie default was never exposed to them.
-
A session cookie could read and write any file on the machine.
FileSessionManagerjoined the cookie's value straight into a path, so
session_id=../../../../etc/cron.d/xaddressed a file outside the session
directory — arbitrary read through the load path, arbitrary write through the
save path, from one request, with no authentication.os.path.joinmade it
worse than traversal: an absolute value discarded the configured directory
altogether, so/root/.ssh/authorized_keysneeded no../at all.A key must now match
[A-Za-z0-9_-]{1,128}, which is wider than the 64 hex
charactersgenerate_session_keyproduces so a project that overrides the
generator keeps working, and excludes every character that can address
another directory. A cookie that fails is treated as though none was sent —
the visitor gets a new session rather than an error, so the response cannot
be used to probe the filesystem._get_file_pathadditionally resolves the
result and refuses anything that leaves the store, which covers what the
pattern did not anticipate, a symlinked storage directory among them. -
Logging in did not change the session identifier.
login()wrote the
user into the session and nothing else, so an identifier known before
authentication was still valid after it: an attacker who fixed a session key
in a victim's browser held an authenticated session the moment the victim
signed in, without stealing a cookie.Session.cycle_key()is new and
login()calls it. The new record is written before the old one is purged,
so a failure part-way through leaves a session that still works rather than
one dropped from under a signed-in user. -
allow_origins=["*"]returnedAccess-Control-Allow-Credentials: trueto
every caller.allow_credentialsdefaulted toTrue, and a wildcard was
answered by reflecting the caller's ownOriginrather than sending*.
Browsers reject a literal*on a credentialed request, and reflecting is
precisely what evades that check — so any site could read responses
authenticated as your users. This was the configuration our own docstrings
showed.allow_credentialsnow defaults toFalse, and combining it with a wildcard
raises at construction rather than being quietly downgraded: both readings of
that configuration are plausible, and guessing would leave whoever wrote it
believing the other. A wildcard now answers with the literal*, so the
response no longer varies by caller and a shared cache cannot serve one
origin's headers to another. -
update_from_dict()wrote any field named to it. Handed a request body
it would set any column, including the ones deciding what a user may do. The
documentation already warned against passing an unvalidated body, which is
weaker than not doing it: models may now declarefillableorguarded, and
a single call may passonly=. A model that states none of them behaves
exactly as before, since plenty of callers pass a dict they built. -
A server-side session was never deleted.
Session.save()cleared
deletedbefore handing the session to the backend, soif session.deleted
was unreachable in both shipped stores and logging out overwrote the file with
{}instead of removing it. No data survived, so this was not exploitable —
but the delete path could not be tested, and session files accumulated
forever. The flags are now cleared after the backend runs.Fixing that exposed the reason it had gone unnoticed:
deletedmeant two
things.__delitem__anddelete()set it for removing one key, while the
backends read it as "purge this session". It now means only the second, which
is whatclear()sets. -
A debug error page could render another request's headers.
ServerErrorMiddlewarestored the request on itself and read it back while
rendering. One instance serves every request —use()keeps the instance it
is given — so the attribute held whichever request wrote to it last, and the
page renders every header it is handed. A request that failed slowly
returned a concurrent request'sCookieandAuthorizationto the wrong
client. It neededAccept: text/html, which is to say a browser, and
debugis on by default.The request is now passed to
generate_html, and the attribute is gone
rather than synchronised: a middleware instance is shared, so per-request
state does not belong on it at all.This was reachable before the chain change below, by registering
ServerErrorMiddlewarethroughuse()— nothing documents doing that, so
the exposure was small, but it was real. The framework's own instance
happened to be rebuilt per request, which concealed it.
Changed
-
The middleware chain is assembled once, outside the request path. It was
rebuilt on every request, allocating a stack of wrapper objects identical to
the previous request's and discarding them. It is now built in__init__
and rebuilt byuse(),add_middleware()and settingdebug— eagerly, so
a request only ever reads it. Routes are unaffected: they live on the
router, which the chain holds by reference.Worth ~1-2% on a request, which is not why it is worth doing; it stops the
per-request garbage. It also turnedServerErrorMiddlewarefrom a
per-request instance into a shared one, which is how the leak above was
found. -
SilloApp.debugis a property. Assigning it rebuilds the chain, because
ServerErrorMiddlewareis constructed with the flag and the chain is no
longer assembled per request.SilloApp(debug=...)is unchanged; the
instance attribute is now_debug. -
ServerErrorMiddleware.generate_html()takes the request. The signature
isgenerate_html(exc, request, limit=7). A caller still passing the old
shape fails with aTypeErrorat the call rather than rendering an
unrelated request. -
logout()empties the whole session rather than removing the one entry
session_keynames. A server-side store purges its record and the browser is
sent an expired cookie, so the identifier logged out of stops being usable by
anyone still holding it. Anything else the session carried goes with it. The
session_keyargument is still accepted and now selects nothing, because
everything is removed. This is whatSession.clear()already documented
itself as being for. -
CorsConfig(allow_credentials=...)defaults toFalse. An application
relying on the old default must now say so, and cannot pair it with"*".
Fixed
Each of these was found by writing the first test to reach the code, which is
why three of them are features that had never worked rather than regressions.
-
HasUlidMixin.generate_ulid()raisedAttributeErroron every install.
It calledulid.new(), which is the API ofulid-py— a different
distribution from thepython-ulidthis package declares. There was no
version of this that worked. A missing package now names the one to install
rather than failing onNone. -
register_transport()registered a backend that could never be
resolved. Inget_transport, the_AVAILABLElookup was indented into
therecordbranch, after its unconditionalreturn— so every custom
backend fell through toValueError: Unknown event backend, and the
documented plugin extension point did nothing observable. The error now
lists registered backends alongside the built-in four. -
pydantic_model_from_tortoiseignoredoptional_fieldsand demanded a
primary key. The two decisions disagreed:optional_fieldswidened the
annotation toOptionalbut still passedField(...), leaving the field
required and satisfiable only by passingNoneexplicitly, while the
primary key was correctly identified as not required and then given a
default of..., which is pydantic's marker for required. Every generated
create schema demanded anidthe database was going to supply. -
OperationalErrormaps to 503, not 500. Unchanged behaviour, recorded
because it now has a test asserting it: an operational error is the database
being unreachable, which is transient and worth retrying.
Testing
Coverage went from 87% to 90%, and the suite from roughly 3,700 tests to
4,247. The Redis-backed classes — the queue backend, the cache, and both
pub/sub transports — were between 0% and 26% because they could only run with
a server present, which is how the bugs fixed in 0.0.1a15 shipped in the first
place. They now run against fakeredis through the injection points the code
already had, so those paths are exercised on every lo...