Skip to content

Releases: sillohq/core

sillo-core-v0.3.1

Choose a tag to compare

@TechWithDunamix TechWithDunamix released this 27 Aug 12:19

[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_messages was silently discarded. CorsConfig.__init__
    built its settings dict with the argument's slot hardcoded to None
    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 an EmailAttachment object.
    Passing a dict worked; passing an already-built EmailAttachment did not —
    the non-dict branch called message.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 negative completed count.
    asyncio.wait() already returns done and pending as disjoint sets, but
    drain() subtracted the cancelled count from len(done) a second time,
    so cancelling any pending task on timeout sent completed negative.

  • A Supervisor on RestartPolicy.ALWAYS (or EXPONENTIAL_BACKOFF)
    restarted a successful task forever.
    max_restarts was 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 of stop().

  • ListenerRegistry.once() never actually unsubscribed a typed-event
    listener.
    It registered a wrapper closure with the dispatcher, then
    called dispatcher.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-new asyncio loop via run_coroutine_threadsafe without ever
    starting that loop running anywhere, so fut.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

Choose a tag to compare

@TechWithDunamix TechWithDunamix released this 21 Aug 14:42

[0.3.0] - 2026-08-21

Added

  • sillo.storage.current_storage() and sillo.storage.bucket(name) — reach the Storage setup_storage registered, from a handler, a queue job, or a script, without the application object or a request.
  • sillo.mail.current_mail() and sillo.mail.send_email(...) — same shape, for the MailClient setup_mail registered.
  • sillo._internals.registry.InstanceRegistry, the shared internal mechanism behind both: a plain slot filled once at startup and read from anywhere, raising NotConfiguredError — not None — when asked before setup_x has run. Documented in Instance Registry.
  • useAuth(unauthorized=..., forbidden=...) — per-route hooks that answer a failed gate directly instead of raising AuthenticationFailed/PermissionDenied for the global exception handler to catch. Each is called as hook(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

Choose a tag to compare

@TechWithDunamix TechWithDunamix released this 19 Aug 14:39

[0.2.1] - 2026-08-19

Changed

  • Replaced itsdangerous with an internal sillo.helpers.signing implementation for CSRF tokens and signed session cookies.
  • Moved sillo.utils modules into sillo.helpers.
  • Fixed typing-extensions dependency marker to require the package on Python < 3.13.

sillo-core-v0.2.0

Choose a tag to compare

@TechWithDunamix TechWithDunamix released this 18 Aug 20:24

What's Changed

Full Changelog: sillo-core-v0.1.0...sillo-core-v0.2.0

sillo-core-v0.1.0

Choose a tag to compare

@TechWithDunamix TechWithDunamix released this 18 Aug 07:58

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-framework

sillo 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
`...

Read more

sillo-core-v0.1.0b1

sillo-core-v0.1.0b1 Pre-release
Pre-release

Choose a tag to compare

[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=allow

Every 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.
    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 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}, which is wider than the 64 hex
    characters generate_session_key produces 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_path additionally 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=["*"] 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: 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 declare fillable or guarded, and
    a single call may pass only=. 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
    deleted before handing the session to the backend, so if 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: deleted meant two
    things. __delitem__ and delete() set it for removing one key, while the
    backends read it as "purge this session". It now means only the second, which
    is what clear() sets.

  • 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 — 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'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 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
    ServerErrorMiddleware through use() — 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 by use(), add_middleware() and setting debug — 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 turned ServerErrorMiddleware from a
    per-request instance into a shared one, which is how the leak above was
    found.

  • SilloApp.debug is a property. Assigning it rebuilds the chain, because
    ServerErrorMiddleware is 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
    is generate_html(exc, request, limit=7). A caller still passing the old
    shape fails with a TypeError at the call rather than rendering an
    unrelated request.

  • logout() empties the whole session rather than removing the one entry
    session_key names. 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_key argument is still accepted and now selects nothing, because
    everything is removed. This is what Session.clear() already documented
    itself as being for.

  • CorsConfig(allow_credentials=...) defaults to False. 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() raised AttributeError on every install.
    It called ulid.new(), which is the API of ulid-py — a different
    distribution from the python-ulid this package declares. There was no
    version of this that worked. A missing package now names the one to install
    rather than failing on None.

  • register_transport() registered a backend that could never be
    resolved.
    In get_transport, the _AVAILABLE lookup was indented into
    the record branch, after its unconditional return — so every custom
    backend fell through to ValueError: 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_tortoise ignored optional_fields and demanded a
    primary key.
    The two decisions disagreed: optional_fields widened the
    annotation to Optional but still passed Field(...), leaving the field
    required and satisfiable only by passing None explicitly, 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 an id the database was going to supply.

  • OperationalError maps 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...

Read more