Respite-API / node_probe.py
cazyundee's picture
security: stop the Space being an open, credentialed relay to the app API
9cc3625 verified
Raw History Blame Contribute Delete
8.59 kB
"""Node backend bridge — runs the existing Next.js Respite backend inside
the Space container (userspace Node, no root) and reverse-proxies to it.
Mounted under /respite/v2/node/*:
/status → is the runtime bootstrapped, is the child alive
/version → runs `node --version` as a child process
/proxy → reverse-proxy an ALLOWLISTED path to the Node backend,
streaming both ways (SSE included)
── Security note (2026-09-28) ────────────────────────────────────────────────
This used to forward *any* path to the Node backend and answer every request
with `Access-Control-Allow-Origin: <whatever the caller sent>` plus
`Access-Control-Allow-Credentials: true`. In practice that turned the Space
into an open, credentialed relay to the whole application API: any website on
the internet could read `/api/auth/me`, `/api/storage`, `/api/stripe/*` and
`/api/billing/*` through it, from a cross-origin context, because this file —
not `src/lib/cors.ts` — was the policy the browser actually enforced. The
app's own CORS code, which deliberately refuses to reflect a wildcard, was
never consulted.
Two changes close that:
* `ALLOWED_PROXY_PATHS` — the relay forwards four paths and returns 404 for
everything else. Chat, follow-ups, search and fetch are the only surfaces
that are meant to be reachable without a session, and the Node app applies
its own auth, quota and rate limits to them.
* `cors_headers()` — an explicit origin allowlist, no reflection, no
wildcard, and no `Allow-Credentials` unless the origin is on it.
The `cookie` header is still forwarded for the allowlisted paths, and that is
deliberate: it is what lets `/api/chat` identify the caller and apply that
user's quota. It is forwarded *only* because the allowlist means there is
nothing else it could be used to reach.
"""
import os
import subprocess
import httpx
from fastapi import Request
from fastapi.responses import JSONResponse
import node_runtime
# The only paths this relay will forward. Everything else is a 404 — see the
# module docstring for why this is an allowlist and not a denylist.
ALLOWED_PROXY_PATHS = frozenset({
"api/chat",
"api/followups",
"api/search",
"api/fetch",
})
# The app origins allowed to read responses cross-origin. The Space is called
# by the browser directly, so this cannot simply be "no CORS at all" — but it
# can be a list, and a list is not a variable the caller supplies.
ALLOWED_ORIGINS = frozenset({
"https://respite.cazyundee.workers.dev",
"https://respite.cazyundee.workers.dev/",
"http://localhost:3000",
"http://127.0.0.1:3000",
})
def _node_path() -> str | None:
try:
return node_runtime.ensure_node()
except Exception:
return None
def cors_headers(request: Request | None = None, methods: str = "GET, POST, OPTIONS") -> dict:
"""Allowlisted CORS. Never reflects the caller's Origin."""
headers = {
"Access-Control-Allow-Methods": methods,
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
}
origin = (request.headers.get("origin") or "").rstrip("/") if request else ""
if origin and origin in ALLOWED_ORIGINS:
headers["Access-Control-Allow-Origin"] = origin
# Only now, and only for a known origin. Sending this alongside a
# reflected origin is what turns a proxy into a credentialed relay.
headers["Access-Control-Allow-Credentials"] = "true"
return headers
def _is_allowed(path: str) -> bool:
"""Normalise and match against the allowlist.
Rejects anything that is not already a bare `api/<name>`: traversal
segments, encoded separators, absolute URLs and double slashes all fail
this test, so they never reach `httpx` with a caller-controlled path.
"""
candidate = path.strip().lstrip("/")
if "//" in candidate or "\\" in candidate:
return False
if ".." in candidate or "%" in candidate or ":" in candidate:
return False
if candidate not in ALLOWED_PROXY_PATHS:
return False
return True
def register_routes(fa_app):
@fa_app.options("/respite/v2/node/proxy/{path:path}")
async def _proxy_preflight(path: str, request: Request):
from starlette.responses import PlainTextResponse
if not _is_allowed(path):
return PlainTextResponse("", status_code=404, headers=cors_headers(request))
return PlainTextResponse("", status_code=204, headers=cors_headers(request))
@fa_app.get("/respite/v2/node/status")
def _status():
# Was returning absolute container paths (`/home/user/app/...`) to
# anyone who asked. It is a liveness probe; it does not need to tell
# a stranger where it lives on disk.
return JSONResponse({
"runtime": "node",
"node_bin_exists": os.path.exists(node_runtime.NODE_BIN),
"backend_ready": node_runtime.node_ready(),
}, headers={"Cache-Control": "no-store"})
@fa_app.get("/respite/v2/node/version")
def _version():
node = _node_path()
if not node:
return JSONResponse({"error": "node bootstrap failed"}, status_code=500)
r = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30)
return JSONResponse({"stdout": r.stdout.strip(), "code": r.returncode})
@fa_app.get("/respite/v2/node/proxy/{path:path}")
@fa_app.post("/respite/v2/node/proxy/{path:path}")
async def _proxy(request: Request, path: str):
"""Forward an allowlisted path to the Node backend on 127.0.0.1:3210,
streaming both ways (SSE included)."""
if not _is_allowed(path):
# 404, not 403: a caller should not be able to probe which paths
# exist behind the relay.
return JSONResponse(
{"error": "Not found"},
status_code=404,
headers=cors_headers(request),
)
if not node_runtime.node_ready():
start_ok = node_runtime.start_node_backend()
if not start_ok:
return JSONResponse(
{"error": "node backend not running"},
status_code=503,
headers=cors_headers(request),
)
url = f"http://127.0.0.1:{node_runtime.NODE_PORT}/{path}"
if request.url.query:
url += "?" + request.url.query
headers = {
k: v for k, v in request.headers.items()
if k.lower() not in ("host", "connection", "content-length", "accept-encoding")
}
# Forwarded deliberately, and only ever to an allowlisted path: it is
# what makes the Node app apply this caller's quota instead of treating
# them as anonymous.
body = await request.body()
try:
client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0))
req = client.build_request(
request.method, url, headers=headers, content=body or None
)
upstream = await client.send(req, stream=True)
resp_headers = {
k: v for k, v in upstream.headers.items()
if k.lower() not in ("content-length", "transfer-encoding", "connection")
}
from starlette.responses import StreamingResponse
async def stream_gen():
try:
async for chunk in upstream.aiter_bytes():
yield chunk
finally:
await upstream.aclose()
await client.aclose()
resp_headers.update(cors_headers(request))
return StreamingResponse(
stream_gen(), status_code=upstream.status_code, headers=resp_headers
)
except Exception as e:
_log_safe(e)
return JSONResponse(
{"error": "upstream unavailable"},
status_code=502,
headers=cors_headers(request),
)
def _log_safe(exc: Exception) -> None:
"""Log the detail server-side; the caller already got a generic 502.
The old handler returned `str(e)` to whoever made the request, which for
an httpx error can include the full upstream URL and headers.
"""
print(f"[node-probe] upstream error: {type(exc).__name__}", flush=True)