Security boundaries¶
This page is short on guarantees on purpose. Most of what makes an application secure is decided outside the framework — the proxy, the identity provider, the database's own permissions — and the useful thing a framework can do is hold a small number of boundaries precisely and say clearly which ones are not its business.
The boundaries, measured¶
"""Walkthrough — the boundaries that hold, and the one that is explicitly yours to hold.
Run it with:
python -m learn_production.security01_boundaries
"""
from __future__ import annotations
import asyncio
import logging
import subprocess
import sys
from pathlib import Path
from typing import ClassVar
from xyberos import PLUGIN_API_VERSION, App, Plugin, Settings
from xyberos.errors import XyberosError
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client
#: The packages an adapter brings in, and therefore the ones core must not import on its own.
ADAPTER_PACKAGES = ("sqlalchemy", "aiosqlite", "alembic", "starlette", "httpx")
class Audit(Plugin[None]):
"""A plugin that registers nothing, so only its metadata is under test."""
name: ClassVar[str] = "audit"
api_version: ClassVar[str] = "2"
def register(self, app: App, config: None) -> None:
"""Register nothing; a plugin for a future API never gets this far."""
class Leaking(Exception):
"""An error that carries a secret and is not safe to expose."""
def __init__(self) -> None:
super().__init__("upstream refused the token sk-live-0123456789")
class Records(logging.Handler):
"""An in-memory handler, so the transcript can be the records themselves."""
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
"""Keep the record instead of writing it anywhere."""
self.records.append(record)
def report(label: str, action: object) -> None:
"""Print the failure a boundary produces, without the value that triggered it."""
try:
action() # type: ignore[operator]
except XyberosError as error:
# One message names the file it could not find, absolutely. Normalising the working
# directory keeps this transcript about the boundary rather than about this machine.
message = str(error).replace(str(Path.cwd()), "<cwd>").replace("\\", "/")
print(f"{label}: {type(error).__name__}: {message}")
def imported_adapter_packages() -> list[str]:
"""Return the adapter packages present after importing the framework, measured fresh."""
script = (
"import sys\n"
"import xyberos\n"
f"names = {ADAPTER_PACKAGES!r}\n"
"print(', '.join(sorted(name for name in names if name in sys.modules)) or 'none')\n"
)
completed = subprocess.run( # noqa: S603 # a fixed script, no shell, no untrusted input
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=True,
)
return completed.stdout.strip().split(", ")
async def leaky_route() -> dict[str, object]:
"""Raise an error whose message must never reach the client."""
raise Leaking
async def scenario() -> None:
"""Measure each boundary: configuration, imports, plugin compatibility, and error bodies."""
# 1. Configuration reports the *setting*, never the value it refused.
report(
"a value that is the wrong type",
lambda: Settings.load(environ={"XYBEROS_APP__DEBUG": "definitely"}),
)
report(
"a setting the runtime does not know",
lambda: Settings.load(environ={"XYBEROS_APP__SIGNING_KEY": "sk-live-0123456789"}),
)
report(
"a configuration file that is not there",
lambda: Settings.load(config_path="nowhere.toml"),
)
# 2. Importing the framework does not import an adapter. The extras are opt-in at the level of
# `sys.modules`, which is what keeps an unused driver out of the deployment's dependency tree.
print("import xyberos pulls in:", ", ".join(imported_adapter_packages()))
print("and the plugin API this runtime supports:", PLUGIN_API_VERSION)
# 3. A plugin written for a different plugin API is refused by name, before it can register.
report("a plugin for another API", lambda: App().add_plugin(Audit()))
# 4. An error that is not an HttpError is replaced in the response, and its message goes only to
# the log. The client learns that the request failed; the operator learns why.
captured = logging.getLogger("xyberos.http")
handler = Records()
captured.addHandler(handler)
previous = captured.level
captured.setLevel(logging.INFO)
try:
app = App()
app.get("/leak")(leaky_route)
with create_test_client(HttpAdapter(app)) as client:
response = client.get("/leak")
print("response body:", response.json())
print(
"and the log, which the operator sees:",
[
(record.getMessage(), str(record.exc_info[1]))
for record in handler.records
if record.exc_info
],
)
finally:
captured.removeHandler(handler)
captured.setLevel(previous)
def main() -> None:
"""Run the scenario."""
asyncio.run(scenario())
if __name__ == "__main__":
main()
a value that is the wrong type: ConfigurationError: app.debug must be a boolean
a setting the runtime does not know: ConfigurationError: unknown setting(s) at app: signing_key
a configuration file that is not there: ConfigurationError: configuration file does not exist: <cwd>/nowhere.toml
import xyberos pulls in: none
and the plugin API this runtime supports: 1
a plugin for another API: PluginCompatibilityError: plugin 'audit' targets plugin API '2', but this runtime supports '1'
response body: {'error': {'type': 'InternalServerError', 'message': 'the request could not be completed'}}
and the log, which the operator sees: [('unhandled error while serving a request', 'upstream refused the token sk-live-0123456789')]
Six boundaries, one line each:
1. A configuration failure names the setting, never the value¶
app.debug must be a boolean and unknown setting(s) at app: signing_key — the second one is the
interesting case, because a signing key is exactly the value that must not appear in a startup log. The
runtime refuses a key it does not know by name and prints nothing about what the value was; the same
holds for plugins.<name> sections, where an unknown key is refused with the key's name only
(the plugins page has that refusal verbatim).
It is worth being precise about what this does and does not buy. It buys: a misspelling is a failed
start rather than a silently ignored setting, and no configuration value is ever echoed by the
runtime's own errors. It does not buy: secrets in the environment are still visible to anything that
can read the process, and Settings.load still holds them in memory. Secrets belong in a manager, and
the security reference is where that is argued.
2. Importing the framework imports no adapter¶
import xyberos pulls in: none, measured in a fresh interpreter against sqlalchemy, aiosqlite,
alembic, starlette, and httpx. The core runtime is the dependency-injection container, the
lifecycle, routing, settings, and the error taxonomy; every capability that needs a third-party package
lives behind an extra, and the import that needs it wraps the failure so a missing package comes back
as install this extra rather than a bare ModuleNotFoundError
the extras
module is the lookup table).
The security consequence is supply chain: a deployment that composes an application without the
database extra does not merely not use a driver, it does not have one installed to be exploited,
and an unused optional dependency cannot be the vulnerable one.
3. A plugin written for another API is refused by name, before it runs¶
plugin 'audit' targets plugin API '2', but this runtime supports '1'. A plugin declares name,
api_version, and config_type, and all three are checked before a line of the plugin executes. This
is compatibility, not protection: a plugin is trusted code
(ADR 0007), the refusal exists so a version mistake is a
startup failure rather than a partially applied registration.
4. An error that is not an HttpError never reaches the client¶
The two lines at the end of the transcript are one rule with two audiences:
response body: {'error': {'type': 'InternalServerError', 'message': 'the request could not be completed'}}
the log: [('unhandled error while serving a request', 'upstream refused the token sk-live-0123456789')]
The client learns that the request failed and nothing else; the operator gets the message and the
traceback. The reverse rule matters just as much: an HttpError and its subclasses are the only
errors whose message is exposed, so a status code and a message a client can act on are a deliberate
choice in the handler, not an accident of what was raised.
5. What the framework does not do, said once¶
| Concern | Who owns it | Where it is argued |
|---|---|---|
| TLS, CORS, header limits, body size, rate limits | the ASGI server and the proxy | security reference |
| Authentication | your identity provider and proxy | the identity lesson |
| Session state and CSRF | xyberos.http middleware you pass, over a store you own |
the sessions example |
| Assertion-header hardening | the proxy must strip what a client sends | the same lesson, and it is the lesson's own warning |
| Authorization | application code, at the edge, per request | the hybrid path's principal lesson |
| Secret storage and rotation | a secret manager | security reference |
| Prompt injection and model output trust | application code, with an allowlist and a schema | the AI security lesson |
| Row-level authorization in the database | application code and the database | the database reference |
| Auditing who did what | application code, in the transaction it describes | the hybrid path's audit lesson |
6. The two boundaries this page will not claim¶
- There is no sandbox. A plugin, a provider, or a model tool runs with the privileges of the
process. "Trusted" is the whole of the guarantee, and it is why nothing can become a plugin by being
installed:
app.add_plugin(...)is the only way in. - There is no tenancy model. A tenant is an application concept, and the framework's contribution is that its request scope gives you one place to put the principal — not that it enforces anything.
A review checklist¶
What to check before a deployment, in the order the mistakes usually happen:
xyberos checkshows the configuration you meant, includingenvironmentanddebug.- Every claim in the model's output is validated before it is used, and every tool the model may call is behind an allowlist (the AI tools lesson).
- No
HttpErrorsubclass carries internal detail in its message — a mapped message is public by definition. - The proxy strips the headers it sets, including
X-Request-IDand any identity assertion. - Internal errors are logged with their traceback and the log sink is not the client's response body (observability).
- Secret material is not in the image, the repository, or a command line —
migrate --urlredacts,psdoes not. - The dependencies you do not use are not installed, which is what the extras are for.
- A failure in authorization is a refusal, not a bypass — the lesson's rule: a missing secret makes
every request
401.
Common mistakes¶
- Logging request bodies to debug an integration. Bodies contain whatever the client sent, including credentials; log identifiers and outcomes instead.
- Trusting a model's output because it parsed. Structured output buys you a shape, not a permission; validate against the domain and authorize separately.
- Adding a
middleware=entry that rewrites errors into the framework's own mapping. Order matters: your middleware runs inside the request scope and after error mapping (observability has the stack), so an error you raise there is mapped, and an error you swallow there is gone. - Treating the
typefield in an error body as sensitive. It is the exception's class name and it is designed to be readable; the message is where restraint applies.