diff --git a/backend/fluksio/cloud/enroll.py b/backend/fluksio/cloud/enroll.py index 7a8e385..468d1b3 100644 --- a/backend/fluksio/cloud/enroll.py +++ b/backend/fluksio/cloud/enroll.py @@ -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/", diff --git a/backend/fluksio/core/config.py b/backend/fluksio/core/config.py index be60e8c..eac2267 100644 --- a/backend/fluksio/core/config.py +++ b/backend/fluksio/core/config.py @@ -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). diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py index 695d546..88f2641 100644 --- a/backend/fluksio/main.py +++ b/backend/fluksio/main.py @@ -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) diff --git a/backend/tests/api/routes/test_docs.py b/backend/tests/api/routes/test_docs.py new file mode 100644 index 0000000..a9b4d24 --- /dev/null +++ b/backend/tests/api/routes/test_docs.py @@ -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 diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 54f95c1..5712ead 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -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: diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 7ed4204..2618b3e 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -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 diff --git a/docs/code/api.md b/docs/code/api.md index ddd913a..8214aaa 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -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. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8748945..5ac5aa6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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