Skip to content

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

examples/learn_production/security01_boundaries.py
"""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:

  1. xyberos check shows the configuration you meant, including environment and debug.
  2. 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).
  3. No HttpError subclass carries internal detail in its message — a mapped message is public by definition.
  4. The proxy strips the headers it sets, including X-Request-ID and any identity assertion.
  5. Internal errors are logged with their traceback and the log sink is not the client's response body (observability).
  6. Secret material is not in the image, the repository, or a command line — migrate --url redacts, ps does not.
  7. The dependencies you do not use are not installed, which is what the extras are for.
  8. 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 type field 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.