Spaces:
Running on Zero
Running on Zero
File size: 8,594 Bytes
6da16aa ba9ed6e 9cc3625 6da16aa 9cc3625 ba9ed6e bb11950 6da16aa ba9ed6e 9cc3625 ba9ed6e 9cc3625 60e15d5 ba9ed6e 60e15d5 9cc3625 60e15d5 9cc3625 60e15d5 ba9ed6e 9cc3625 ba9ed6e 9cc3625 ba9ed6e 9cc3625 ba9ed6e 9cc3625 ba9ed6e bb11950 9cc3625 ba9ed6e 9cc3625 ba9ed6e abb3d7d 9cc3625 abb3d7d ba9ed6e abb3d7d 9cc3625 abb3d7d ba9ed6e 9cc3625 79c96e0 9cc3625 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | """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)
|