Enrol over http only to loopback; serve the API docs to this machine

The enrolment exemption covered any *.local host, which mDNS answers with
whoever claims it; it is now localhost, 127.0.0.0/8 and ::1. /docs, /redoc
and openapi.json stay closed in production and otherwise answer only a
loopback caller unless API_DOCS_ENABLED is set, which the dev compose does,
so `fluksio serve --host 0.0.0.0` no longer hands the schema to the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
2026-10-06 12:20:13 +02:00
co-authored by Claude Opus 5.5
parent cf5329e3a8
commit b0c601c6ed
8 changed files with 124 additions and 19 deletions
+15 -7
View File
@@ -9,6 +9,7 @@ before the engine has started, holding nothing but the database.
from __future__ import annotations
import ipaddress
import logging
import secrets
from datetime import UTC, datetime
@@ -37,10 +38,19 @@ class EnrollError(Exception):
self.detail = detail
def _is_local(url: str) -> bool:
"""Whether the address is this machine or a compose-internal service."""
def _is_loopback(url: str) -> bool:
"""Whether the address is this machine.
Not a `*.local` name: mDNS answers it with whoever claims it on the LAN,
which would then receive the claim code and the credential in clear.
"""
host = urlsplit(url).hostname or ""
return host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local")
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def redeem_claim(
@@ -52,10 +62,8 @@ def redeem_claim(
# this address. Over plain http both are readable by anything on the path,
# so refuse rather than enrol insecurely — bar a loopback portal, which is
# how the stack is developed against itself.
if not base.startswith("https://") and not _is_local(base):
raise EnrollError(
422, "The portal address must be https:// (or a local address)"
)
if not base.startswith("https://") and not _is_loopback(base):
raise EnrollError(422, "The portal address must be https:// (or this machine)")
try:
response = httpx.post(
f"{base}/api/v1/enroll/",
+3
View File
@@ -103,6 +103,9 @@ class Settings(BaseSettings):
# opt-in on top of ENVIRONMENT=local, so a deployment that merely kept the
# default environment never exposes them.
PRIVATE_API_ENABLED: bool = False
# `/docs`, `/redoc` and the OpenAPI document for any caller, rather than
# only one on this machine. Never in production.
API_DOCS_ENABLED: bool = False
# Whether anyone who reaches the login page may create an account. Off
# unless asked for: an account can change what flows do, so who gets one
# is a superuser's call (Admin → Users).
+45 -8
View File
@@ -2,6 +2,7 @@ import asyncio
import contextlib
import hashlib
import inspect
import ipaddress
import logging
import sys
import tempfile
@@ -16,6 +17,7 @@ from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from fluksio_worker.worker_main import ARTIFACT_DIR_ENV, ARTIFACT_VOLATILE_DIR_ENV
from starlette.middleware.cors import CORSMiddleware
from starlette.types import ASGIApp, Receive, Scope, Send
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from fluksio import __version__
@@ -409,10 +411,43 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# The schema enumerates every endpoint this instance serves, including the
# paths trigger nodes mount at runtime. That is exactly what a developer wants
# and exactly what an internet-facing deployment should not hand out, so it
# follows the environment — the same rule the portal's backend uses. The
# generated client is built from a local run, not from the deployed host.
# and exactly what a reachable engine should not hand out: never in
# production, and otherwise only to a caller on this machine unless
# API_DOCS_ENABLED says so (the dev stack does). `fluksio serve --host 0.0.0.0`
# keeps the default ENVIRONMENT, which is why loopback is the line rather than
# the environment alone. The generated client is built from `app.openapi()`
# and needs none of these routes.
_docs_enabled = settings.ENVIRONMENT != "production"
_DOCS_PATHS = frozenset(
{"/docs", "/docs/oauth2-redirect", "/redoc", f"{settings.API_V1_STR}/openapi.json"}
)
class DocsForThisMachine:
"""Answer the schema routes with a 404 for a caller off this machine."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] == "http"
and scope["path"] in _DOCS_PATHS
and not settings.API_DOCS_ENABLED
and not _is_loopback((scope.get("client") or ("",))[0])
):
response = JSONResponse({"detail": "Not Found"}, status_code=404)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def _is_loopback(host: str) -> bool:
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
app = FastAPI(
title=settings.PROJECT_NAME,
@@ -434,11 +469,13 @@ if settings.all_cors_origins:
allow_headers=["*"],
)
# The caller's address, for every per-address limit: the rightmost
# X-Forwarded-For hop that is not a trusted proxy. uvicorn does this for what
# it serves, but a call the portal tunnel replays arrives over an in-process
# transport as 127.0.0.1 and never passes through it. Doing it again here
# covers that, with uvicorn's own semantics, and is a no-op on a request
app.add_middleware(DocsForThisMachine)
# The caller's address, for every per-address limit and the docs check above:
# the rightmost X-Forwarded-For hop that is not a trusted proxy. uvicorn does
# this for what it serves, but a call the portal tunnel replays arrives over an
# in-process transport as 127.0.0.1 and never passes through it. Doing it again
# here covers that, with uvicorn's own semantics, and is a no-op on a request
# uvicorn already resolved. Added last, so it is the outermost layer.
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.FORWARDED_ALLOW_IPS)
+29
View File
@@ -0,0 +1,29 @@
"""The interactive schema: for this machine, or whoever API_DOCS_ENABLED names."""
from unittest.mock import patch
from fastapi.testclient import TestClient
from fluksio.core.config import settings
from fluksio.main import app
SCHEMA = f"{settings.API_V1_STR}/openapi.json"
def test_the_schema_is_served_only_on_this_machine_by_default(
client: TestClient,
) -> None:
"""`fluksio serve --host 0.0.0.0` keeps ENVIRONMENT=local, and the network."""
for path in (SCHEMA, "/docs", "/redoc"):
assert client.get(path).status_code == 404 # "testclient" is not loopback
here = TestClient(app, client=("127.0.0.1", 50000))
assert here.get(SCHEMA).status_code == 200
assert here.get("/docs").status_code == 200
# Through a proxy on this machine, the caller is what counts.
forwarded = {"x-forwarded-for": "203.0.113.7"}
assert here.get(SCHEMA, headers=forwarded).status_code == 404
with patch("fluksio.core.config.settings.API_DOCS_ENABLED", True):
assert client.get(SCHEMA).status_code == 200
+21
View File
@@ -182,6 +182,27 @@ def test_a_claim_the_portal_refuses_keeps_the_connection(
assert db.get(User, enrolled.id).portal_sub == "portal-user-1"
def test_a_cleartext_portal_must_be_this_machine() -> None:
"""An mDNS name is answered by whoever claims it on the LAN.
Over http, that is who would receive the claim code and the credential.
"""
from fluksio.cloud import enroll
with patch("fluksio.cloud.enroll.httpx.post") as post:
for portal in ("http://hub.local", "http://192.168.1.20:8000"):
with pytest.raises(enroll.EnrollError) as refused:
enroll.redeem_claim(portal, "ABCD-EFGH")
assert refused.value.status == 422
post.assert_not_called()
post.return_value = Mock(status_code=404)
for portal in ("http://localhost:8000", "http://127.0.0.2", "http://[::1]"):
with pytest.raises(enroll.EnrollError) as unknown:
enroll.redeem_claim(portal, "ABCD-EFGH")
assert unknown.value.status == 400 # past the check, at the portal
def test_enrolling_needs_a_superuser(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
+2
View File
@@ -124,6 +124,8 @@ services:
EMAILS_FROM_EMAIL: "info@fluksio.com"
# Test-only user seeding, needed by the Playwright suite. Dev stack only.
PRIVATE_API_ENABLED: "true"
# /docs and the schema for callers through Traefik, not only loopback.
API_DOCS_ENABLED: "true"
mailcatcher:
image: schickling/mailcatcher
+5 -3
View File
@@ -30,8 +30,9 @@ Agents authenticate differently; see [Agents over MCP](agents.md).
When `ENVIRONMENT` is not `production`, the full OpenAPI schema is at
`/docs` (Swagger) and `/redoc`, and the raw document at
`/api/v1/openapi.json`. That is the authoritative reference; this page is
the tour.
`/api/v1/openapi.json`, for a browser on the engine's own machine (or any,
with `API_DOCS_ENABLED=true`). That is the authoritative reference; this
page is the tour.
It is closed in production: the schema enumerates every endpoint the
instance serves, including paths webhook nodes mounted at runtime.
@@ -260,4 +261,5 @@ stored version moved past the one you were editing. Re-read, merge, save again.
The frontend's TypeScript client is generated from the OpenAPI schema
(`make generate-client`). Any OpenAPI generator will do the same for your
language: point it at `/api/v1/openapi.json` on a non-production instance.
language: point it at `/api/v1/openapi.json` on a non-production instance,
from the machine it runs on.
+4 -1
View File
@@ -112,6 +112,7 @@ remember it is fixed at build time: changing it means rebuilding that image.
|---|---|---|
| `ENVIRONMENT` | `local` | `local`, `staging` or `production` |
| `PRIVATE_API_ENABLED` | `false` | unauthenticated test-only endpoints; needs `ENVIRONMENT=local` too |
| `API_DOCS_ENABLED` | `false` | serves `/docs`, `/redoc` and the OpenAPI document to any caller; otherwise only to one on this machine. Never in `production` |
| `SIGNUP_ENABLED` | `false` | lets anyone who reaches the login page create an account; otherwise a superuser adds them under **Admin** |
| `TZ` | `UTC` | the timezone every schedule is written in |
@@ -123,7 +124,9 @@ instance is.
`production` closes `/docs`, `/redoc` and the OpenAPI document, because the
schema enumerates every endpoint the instance serves, including the paths
webhook nodes mounted at runtime. It also turns a `changethis` secret from a
webhook nodes mounted at runtime. Elsewhere they answer a caller on the
engine's own machine, so `fluksio serve --host 0.0.0.0` does not hand them to
the network; `API_DOCS_ENABLED` opens them to everyone, as the dev stack does. It also turns a `changethis` secret from a
warning into a refusal to start.
## The engine