Serving CSS, JavaScript, images and downloads with sillo's StaticFiles app — mounting, multiple roots, extension allow-lists, path-traversal protection, cache headers, and the conditional-request and HEAD behaviour it does not implement.
Static Files
Section titled “Static Files”sillo.static.StaticFiles is a small ASGI application that maps a URL path
onto one or more directories on disk. You mount it under a prefix, it
looks for a matching file, and it streams the file back with a content
type, an ETag, a Last-Modified date, and whatever Cache-Control you
configured.
It is deliberately narrow. It resolves a path, checks that the resolved path is still inside the directory you gave it, checks the extension against an optional allow-list, and serves. Everything a CDN or a reverse proxy does better — conditional revalidation, compression, directory indexes — it does not do. That boundary is worth understanding before you put it in front of production traffic, and the whole of this page is really about where that boundary sits.
from sillo import silloAppfrom sillo.core.routing import Groupfrom sillo.static import StaticFiles
app = silloApp()
app.add_route( Group(path="/static", app=StaticFiles(directory="public")))A file at public/css/style.css is now served at /static/css/style.css.
Mounting
Section titled “Mounting”StaticFiles has no notion of a URL prefix. It reads scope["path"],
strips the leading slash, and joins the result onto each configured
directory. The prefix comes entirely from the Group you mount it in —
Group.handle rewrites scope["path"] to the portion after the prefix
before delegating, so the static app only ever sees the relative part.
Router.register(app, prefix=...) also works and does the same thing
internally, but it emits a DeprecationWarning telling you to use Group
directly. Use Group.
Constructor arguments
Section titled “Constructor arguments”| Argument | Type | Effect |
|---|---|---|
directory | str | Path | A single root to serve from |
directories | list[str | Path] | Several roots, searched in order |
allowed_extensions | list[str] | Allow-list; empty or None means everything |
custom_404_handler | callable | Called when nothing matched — must be sync |
cache_control | str | Verbatim Cache-Control value on every hit |
If both directory and directories are given, directories wins and
directory is silently ignored. If neither is given, the app serves
nothing and every request 404s — which is a confusing failure mode, so
double-check the argument name if a freshly mounted static app returns
nothing but 404s.
Multiple directories
Section titled “Multiple directories”Files are searched in the order the list is written, and the first match wins:
StaticFiles(directories=["overrides", "vendor", "public"])A request for /static/logo.svg checks overrides/logo.svg, then
vendor/logo.svg, then public/logo.svg. This is the mechanism for
shipping a default asset set and letting a deployment override individual
files without rebuilding the package.
The cost is that a file shadowing another file is invisible. Nothing warns
you that overrides/app.js is masking public/app.js; you find out when
a fix you made to the wrong copy does not appear. Keep overlay
directories small and obvious, or use one root and build your assets into
it.
Path traversal
Section titled “Path traversal”Every candidate path is resolved to an absolute path and checked with
Path.is_relative_to against the resolved root. Anything that escapes is
skipped:
GET /static/../secret.txt -> 404GET /static/%2e%2e/secret.txt -> 404The check happens after resolve(), so it also covers symlinks that point
outside the root — a symlinked file inside public that targets
/etc/passwd resolves outside the root and is refused. Percent-encoded
traversal is decoded by the server before sillo sees it, so it is caught
by the same check.
This is the one piece of security the class implements properly, and it is
worth knowing that it is the only piece. It does not hide dotfiles.
public/.env is served like any other file if the extension filter
permits it. Do not put a directory that contains secrets, .git, or build
configuration behind StaticFiles and rely on obscurity.
Extension filtering
Section titled “Extension filtering”StaticFiles( directory="public", allowed_extensions=["css", "js", "png", "jpg", "svg", "woff2"],)Extensions are normalised by lowercasing and stripping a leading dot, so
"css", ".css" and ".CSS" are all the same rule. An empty list or
None disables the filter entirely.
Two behaviours surprise people:
Extensionless files are always refused when a filter is set. A file
named LICENSE or CHANGELOG has an empty suffix, which is never in the
allow-list, so it 404s. If you need to serve them, give them a .txt
suffix or drop the filter.
The filter is not a security boundary on its own. It stops
/static/app.py from being read as source, which is genuinely useful when
your asset directory sits inside your package. It does nothing about the
files you did allow. An allow-list containing html plus a directory
that users can write to is a stored-XSS delivery mechanism regardless of
the filter.
Caching, and what is missing
Section titled “Caching, and what is missing”cache_control is written verbatim onto every successful response:
StaticFiles(directory="dist", cache_control="public, max-age=31536000, immutable")immutable is only correct when filenames contain a content hash
(app.7f3c1a.js), because the browser will not revalidate for a year. For
assets served under a stable name, a short max-age with must-revalidate
is the honest setting.
The response also carries an ETag and a Last-Modified header,
generated by the underlying file response:
content-type: text/csscontent-disposition: inline; filename="style.css"accept-ranges: bytescache-control: public, max-age=60content-length: 15last-modified: Tue, 28 Jul 2026 08:31:36 GMTetag: "48c914b11e6cea25351c14bca7f15540"Range requests, by contrast, do work — accept-ranges: bytes is honest,
and Range: bytes=0-3 returns a 206 with four bytes. Video and audio
seeking behaves correctly.
Methods
Section titled “Methods”Only GET is served. Everything else returns 405:
POST /static/css/style.css -> 405 "Method not allowed"HEAD /static/css/style.css -> 405The HEAD case is a defect rather than a policy. HEAD is defined as
GET without a body, and health checks, link checkers, uptime monitors
and some CDN origin-pull implementations use it to verify an asset exists
without transferring it. Against sillo they all report the asset as
method-not-allowed.
If something in your infrastructure needs HEAD, handle it before the
mount:
@app.head("/static/{path:path}")async def static_head(request, response): return response.text("", status_code=200)That is a blunt fix — it does not confirm the file exists — so prefer
pointing the checker at a real route, or serving assets from a proxy that
implements HEAD properly.
The 404 path
Section titled “The 404 path”When no directory yields a match, the default response is:
"Resource not found"Note the shape: a bare JSON string, not the
{"status": 404, "error": "Not Found", ...} object that the rest of the
framework produces. A frontend
that parses response.error for every failure will get undefined here.
A custom handler replaces it:
def missing_asset(request, response): return response.json( {"status": 404, "error": "Not Found", "detail": "asset not found"}, status_code=404, )
StaticFiles(directory="public", custom_404_handler=missing_asset)No directory index
Section titled “No directory index”StaticFiles never serves an index document. With public/index.html on
disk:
GET /static/index.html -> 200GET /static/ -> 404There is no index.html fallback and no directory listing (the absence of
a listing is a good thing; the absence of the fallback usually is not).
For a single-page application you want /guides/frontend/, which does
implement the SPA fallback. For a documentation-style tree, link to the
.html files explicitly or add a route that rewrites the trailing slash.
What not to do
Section titled “What not to do”Do not pass prefix= to StaticFiles. It raises TypeError. Mount
with Group(path=...).
Do not import Group from sillo.routing. That module does not
exist; use sillo.core.routing.
Do not write custom_404_handler as async def. It produces a 500.
Do not serve your source tree. The directory is served as-is —
dotfiles, .env, .git and all — filtered only by extension if you set
a filter.
Do not rely on HEAD working. It returns 405.
Do not assume 304s. Validators are emitted but never checked; every request transfers the whole file.
Do not use immutable on unhashed filenames. You will not be able to
ship a fix for a year of browser cache.
Do not let a typo’d directory pass silently. The path is created for you; assert it exists if that matters.
Related
Section titled “Related”- Frontend (SPA) — the SPA fallback
StaticFileslacks - Routers & Sub-Apps — how
Grouprewrites the path - Sending Responses —
response.fileand its headers - Caching — application-level caching, a different concern
- Security Headers — headers worth adding around asset delivery