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:
@@ -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/",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user