fix(security): P0-1 — port exposure + service-token auth on /internal/* + jupyter RPC
The fix lands in three concentric layers, all backed by a single
INTERNAL_SERVICE_TOKEN shared secret so we have one mechanism
instead of three:
1. docker-compose: drop the backend.ports: 8891:8000 and
runtime.ports: 8892:8000 mappings. Nginx is the only host
ingress again (architecture §2.2).
2. /internal/v1/*: the storage control plane had six endpoints, five
of which were dead code (frontend already migrated to
/api/v1/data-resources/* with JWT; schedule only ever called
POST /internal/v1/objects). Delete the dead routes, mount the
one survivor with Depends(require_internal_service) that
compares the X-Internal-Service-Token header against
settings.internal_service_token with secrets.compare_digest.
3. POST /api/v1/jupyter on the runtime container: previously open
inside the Docker network. Same token mechanism — backend's
runtime_http_client now carries the header, runtime's
handle_jupyter_action requires the same header. /api/v1/health
stays open for the Nginx and compose healthchecks.
The schedule worker was already configured to call
POST /internal/v1/objects; build_storage_http_client now
sets the token header so its existing call site keeps working
without changes.
Files touched:
backend/src/backend/storage_api.py # 5 dead routes deleted + token guard
backend/src/backend/main.py # runtime_http_client header
runtime/src/runtime/main.py # require_internal_service Depends
common/src/common/config.py # internal_service_token setting
schedule/src/schedule/service.py # httpx client header
docker-compose.yml # ports dropped, INTERNAL_SERVICE_TOKEN env
.env.example # INTERNAL_SERVICE_TOKEN placeholder
API.md / README.md / DEVELOP.md # §9 trimmed to 1 endpoint
Verified:
compileall -> 0 errors
pytest backend/tests -> 37 passed
in-process ASGI smoke:
POST /internal/v1/objects no/wrong/correct token -> 401/401/200
POST /api/v1/jupyter no/wrong/correct token -> 401/401/200
5 deleted internal routes -> 404
docker compose config (with env) -> OK
P0-1 still has one open sub-item (rclone RC --rc-no-auth) that
the user has explicitly deferred; not touched here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6258cf5d12
commit
dfe3f0b118
@@ -8,12 +8,21 @@ surface is two endpoints; everything else is lifespan orchestration.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, status
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
# Import Settings directly: the runtime process does not own a DB
|
||||
# session, so we can't reuse ``request_context``. We DO reuse the same
|
||||
# shared secret idea (``/internal/v1/*`` uses the exact same value),
|
||||
# because inventing a second token scheme for one extra hop would be
|
||||
# pure complexity.
|
||||
from common.config import settings
|
||||
from runtime.mount import start_rclone_mount, stop_rclone_mount
|
||||
from runtime.process import (
|
||||
JUPYTER_PROCESSES,
|
||||
@@ -27,6 +36,37 @@ from runtime.process import (
|
||||
stop_workspace,
|
||||
)
|
||||
|
||||
INTERNAL_SERVICE_TOKEN_HEADER = "x-internal-service-token"
|
||||
|
||||
|
||||
def require_internal_service(
|
||||
x_internal_service_token: str | None = Header(default=None),
|
||||
) -> None:
|
||||
"""Enforce a shared secret on /api/v1/jupyter.
|
||||
|
||||
The only legitimate caller is the backend (already authenticated
|
||||
via cookie / Bearer JWT), which forwards the request after running
|
||||
``request_context``. The runtime process does not own a DB session
|
||||
so it cannot validate the JWT itself — the token header is the
|
||||
cheaper defense-in-depth equivalent.
|
||||
|
||||
Empty backend config -> 503 (we'd rather fail loud than silently
|
||||
allow all callers when deployment hasn't been initialised).
|
||||
"""
|
||||
expected = settings.internal_service_token
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"internal service token not configured",
|
||||
)
|
||||
if not x_internal_service_token or not secrets.compare_digest(
|
||||
x_internal_service_token, expected
|
||||
):
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"internal service token required",
|
||||
)
|
||||
|
||||
|
||||
class JupyterActionRequest(BaseModel):
|
||||
action: Literal["start", "stop", "list", "get"]
|
||||
@@ -91,7 +131,7 @@ def healthz() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/v1/jupyter")
|
||||
@app.post("/api/v1/jupyter", dependencies=[Depends(require_internal_service)])
|
||||
async def handle_jupyter_action(req: JupyterActionRequest) -> dict:
|
||||
match req.action:
|
||||
case "start":
|
||||
|
||||
Reference in New Issue
Block a user