Skip to content

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.

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.

the whole API
from sillo import silloApp
from sillo.core.routing import Group
from 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.

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.

ArgumentTypeEffect
directorystr | PathA single root to serve from
directorieslist[str | Path]Several roots, searched in order
allowed_extensionslist[str]Allow-list; empty or None means everything
custom_404_handlercallableCalled when nothing matched — must be sync
cache_controlstrVerbatim 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.

Files are searched in the order the list is written, and the first match wins:

overlay roots
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.

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 -> 404
GET /static/%2e%2e/secret.txt -> 404

The 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.

allow-list
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.

cache_control is written verbatim onto every successful response:

immutable, content-hashed assets
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/css
content-disposition: inline; filename="style.css"
accept-ranges: bytes
cache-control: public, max-age=60
content-length: 15
last-modified: Tue, 28 Jul 2026 08:31:36 GMT
etag: "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.

Only GET is served. Everything else returns 405:

POST /static/css/style.css -> 405 "Method not allowed"
HEAD /static/css/style.css -> 405

The 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:

answering HEAD in front of 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.

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:

custom 404 for missing assets
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)

StaticFiles never serves an index document. With public/index.html on disk:

GET /static/index.html -> 200
GET /static/ -> 404

There 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.

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.