Spaces:
Running on Zero
Running on Zero
Download node_probe.py from cazyundee/Respite-API: direct link, hf CLI and curl.
- Browser
- Download file 8.59 kB
-
https://huggingface.co/spaces/cazyundee/Respite-API/resolve/main/node_probe.py
- Command line
-
hf download hf://spaces/cazyundee/Respite-API/node_probe.py
-
curl -L -o node_probe.py https://huggingface.co/spaces/cazyundee/Respite-API/resolve/main/node_probe.py
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): | |
| 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)) | |
| 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"}) | |
| 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}) | |
| 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) | |