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:
tao.chen
2026-08-17 16:48:46 +08:00
co-authored by Claude Fable 5
parent 6258cf5d12
commit dfe3f0b118
10 changed files with 195 additions and 138 deletions
+42 -2
View File
@@ -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":