cazyundee commited on
Commit
9cc3625
·
verified ·
1 Parent(s): 6baa2e1

security: stop the Space being an open, credentialed relay to the app API

Browse files
Files changed (1) hide show
  1. node_probe.py +120 -66
node_probe.py CHANGED
@@ -4,8 +4,33 @@ the Space container (userspace Node, no root) and reverse-proxies to it.
4
  Mounted under /respite/v2/node/*:
5
  /status → is the runtime bootstrapped, is the child alive
6
  /version → runs `node --version` as a child process
7
- /proxy → reverse-proxy <path> (+ query/body/headers) to the Node backend,
8
  streaming both ways (SSE included)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
 
11
  import os
@@ -17,6 +42,25 @@ from fastapi.responses import JSONResponse
17
 
18
  import node_runtime
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  def _node_path() -> str | None:
22
  try:
@@ -25,28 +69,58 @@ def _node_path() -> str | None:
25
  return None
26
 
27
 
28
- CORS_HEADERS = {
29
- "Access-Control-Allow-Origin": "*",
30
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
31
- "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept",
32
- "Access-Control-Max-Age": "86400",
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  def register_routes(fa_app):
37
  @fa_app.options("/respite/v2/node/proxy/{path:path}")
38
- async def _proxy_preflight(path: str):
39
  from starlette.responses import PlainTextResponse
40
- return PlainTextResponse("", status_code=204, headers=CORS_HEADERS)
 
 
41
 
42
  @fa_app.get("/respite/v2/node/status")
43
  def _status():
 
 
 
44
  return JSONResponse({
45
- "runtime_dir": node_runtime.RUNTIME_DIR,
46
  "node_bin_exists": os.path.exists(node_runtime.NODE_BIN),
47
  "backend_ready": node_runtime.node_ready(),
48
- "backend_dir": node_runtime.BACKEND_DIR,
49
- })
50
 
51
  @fa_app.get("/respite/v2/node/version")
52
  def _version():
@@ -54,17 +128,29 @@ def register_routes(fa_app):
54
  if not node:
55
  return JSONResponse({"error": "node bootstrap failed"}, status_code=500)
56
  r = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30)
57
- return JSONResponse({"stdout": r.stdout.strip(), "stderr": r.stderr.strip()[:400], "code": r.returncode})
58
 
59
  @fa_app.get("/respite/v2/node/proxy/{path:path}")
60
  @fa_app.post("/respite/v2/node/proxy/{path:path}")
61
  async def _proxy(request: Request, path: str):
62
- """Forward method, query string, body and headers to the Node backend
63
- on 127.0.0.1:3210. Streaming both ways (SSE included)."""
 
 
 
 
 
 
 
 
64
  if not node_runtime.node_ready():
65
  start_ok = node_runtime.start_node_backend()
66
  if not start_ok:
67
- return JSONResponse({"error": "node backend not running"}, status_code=503)
 
 
 
 
68
  url = f"http://127.0.0.1:{node_runtime.NODE_PORT}/{path}"
69
  if request.url.query:
70
  url += "?" + request.url.query
@@ -72,6 +158,9 @@ def register_routes(fa_app):
72
  k: v for k, v in request.headers.items()
73
  if k.lower() not in ("host", "connection", "content-length", "accept-encoding")
74
  }
 
 
 
75
  body = await request.body()
76
  try:
77
  client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0))
@@ -93,58 +182,23 @@ def register_routes(fa_app):
93
  await upstream.aclose()
94
  await client.aclose()
95
 
96
- resp_headers.update(CORS_HEADERS)
97
  return StreamingResponse(
98
  stream_gen(), status_code=upstream.status_code, headers=resp_headers
99
  )
100
  except Exception as e:
101
- return JSONResponse({"error": str(e)}, status_code=502)
102
-
103
- @fa_app.get("/respite/v2/live-frames")
104
- async def _live_frames(q: str = "Write three short paragraphs about rivers."):
105
- """TEMP DIAGNOSTIC: open a Live socket, log outputTranscription frame
106
- timings/count. Returns only timing metadata — never the API key."""
107
- import asyncio, json, time
108
- websockets = None
109
- try:
110
- import websockets as _ws
111
- websockets = _ws
112
- except ImportError:
113
- return JSONResponse({"error": "websockets not installed"})
114
- key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
115
- if not key:
116
- return JSONResponse({"error": "no google key in env"})
117
- url = f"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key={key}"
118
- frames = [{"t": 0, "note": "route entered"}]
119
- t0 = time.monotonic()
120
-
121
- async def run():
122
- async with websockets.connect(url, max_size=None) as ws:
123
- await ws.send(json.dumps({"setup": {"model": "models/gemini-3.8-live"}}))
124
- setup = True
125
- while True:
126
- try:
127
- raw = await asyncio.wait_for(ws.recv(), timeout=45)
128
- except asyncio.TimeoutError:
129
- frames.append({"t": round(time.monotonic() - t0, 2), "err": "timeout"})
130
- break
131
- except Exception as e:
132
- frames.append({"t": round(time.monotonic() - t0, 2), "err": repr(e)[:200]})
133
- break
134
- try:
135
- f = json.loads(raw)
136
- except Exception:
137
- continue
138
- if f.get("setupComplete"):
139
- await ws.send(json.dumps({"clientContent": {"turns": [{"role": "user", "parts": [{"text": q}]}], "turnComplete": True}}))
140
- setup = False
141
- continue
142
- frames.append({"t": round(time.monotonic() - t0, 2), "keys": sorted(f.keys()), "raw": json.dumps(f)[:400]})
143
- if len(frames) >= 6:
144
- break
145
 
146
- try:
147
- await asyncio.wait_for(run(), timeout=50)
148
- except Exception as e:
149
- return JSONResponse({"error": str(e), "frames": frames})
150
- return JSONResponse({"frames": frames, "count": len(frames), "done": True})
 
 
 
 
4
  Mounted under /respite/v2/node/*:
5
  /status → is the runtime bootstrapped, is the child alive
6
  /version → runs `node --version` as a child process
7
+ /proxy → reverse-proxy an ALLOWLISTED path to the Node backend,
8
  streaming both ways (SSE included)
9
+
10
+ ── Security note (2026-09-28) ────────────────────────────────────────────────
11
+ This used to forward *any* path to the Node backend and answer every request
12
+ with `Access-Control-Allow-Origin: <whatever the caller sent>` plus
13
+ `Access-Control-Allow-Credentials: true`. In practice that turned the Space
14
+ into an open, credentialed relay to the whole application API: any website on
15
+ the internet could read `/api/auth/me`, `/api/storage`, `/api/stripe/*` and
16
+ `/api/billing/*` through it, from a cross-origin context, because this file —
17
+ not `src/lib/cors.ts` — was the policy the browser actually enforced. The
18
+ app's own CORS code, which deliberately refuses to reflect a wildcard, was
19
+ never consulted.
20
+
21
+ Two changes close that:
22
+
23
+ * `ALLOWED_PROXY_PATHS` — the relay forwards four paths and returns 404 for
24
+ everything else. Chat, follow-ups, search and fetch are the only surfaces
25
+ that are meant to be reachable without a session, and the Node app applies
26
+ its own auth, quota and rate limits to them.
27
+ * `cors_headers()` — an explicit origin allowlist, no reflection, no
28
+ wildcard, and no `Allow-Credentials` unless the origin is on it.
29
+
30
+ The `cookie` header is still forwarded for the allowlisted paths, and that is
31
+ deliberate: it is what lets `/api/chat` identify the caller and apply that
32
+ user's quota. It is forwarded *only* because the allowlist means there is
33
+ nothing else it could be used to reach.
34
  """
35
 
36
  import os
 
42
 
43
  import node_runtime
44
 
45
+ # The only paths this relay will forward. Everything else is a 404 — see the
46
+ # module docstring for why this is an allowlist and not a denylist.
47
+ ALLOWED_PROXY_PATHS = frozenset({
48
+ "api/chat",
49
+ "api/followups",
50
+ "api/search",
51
+ "api/fetch",
52
+ })
53
+
54
+ # The app origins allowed to read responses cross-origin. The Space is called
55
+ # by the browser directly, so this cannot simply be "no CORS at all" — but it
56
+ # can be a list, and a list is not a variable the caller supplies.
57
+ ALLOWED_ORIGINS = frozenset({
58
+ "https://respite.cazyundee.workers.dev",
59
+ "https://respite.cazyundee.workers.dev/",
60
+ "http://localhost:3000",
61
+ "http://127.0.0.1:3000",
62
+ })
63
+
64
 
65
  def _node_path() -> str | None:
66
  try:
 
69
  return None
70
 
71
 
72
+ def cors_headers(request: Request | None = None, methods: str = "GET, POST, OPTIONS") -> dict:
73
+ """Allowlisted CORS. Never reflects the caller's Origin."""
74
+ headers = {
75
+ "Access-Control-Allow-Methods": methods,
76
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept",
77
+ "Access-Control-Max-Age": "86400",
78
+ "Vary": "Origin",
79
+ }
80
+ origin = (request.headers.get("origin") or "").rstrip("/") if request else ""
81
+ if origin and origin in ALLOWED_ORIGINS:
82
+ headers["Access-Control-Allow-Origin"] = origin
83
+ # Only now, and only for a known origin. Sending this alongside a
84
+ # reflected origin is what turns a proxy into a credentialed relay.
85
+ headers["Access-Control-Allow-Credentials"] = "true"
86
+ return headers
87
+
88
+
89
+ def _is_allowed(path: str) -> bool:
90
+ """Normalise and match against the allowlist.
91
+
92
+ Rejects anything that is not already a bare `api/<name>`: traversal
93
+ segments, encoded separators, absolute URLs and double slashes all fail
94
+ this test, so they never reach `httpx` with a caller-controlled path.
95
+ """
96
+ candidate = path.strip().lstrip("/")
97
+ if "//" in candidate or "\\" in candidate:
98
+ return False
99
+ if ".." in candidate or "%" in candidate or ":" in candidate:
100
+ return False
101
+ if candidate not in ALLOWED_PROXY_PATHS:
102
+ return False
103
+ return True
104
 
105
 
106
  def register_routes(fa_app):
107
  @fa_app.options("/respite/v2/node/proxy/{path:path}")
108
+ async def _proxy_preflight(path: str, request: Request):
109
  from starlette.responses import PlainTextResponse
110
+ if not _is_allowed(path):
111
+ return PlainTextResponse("", status_code=404, headers=cors_headers(request))
112
+ return PlainTextResponse("", status_code=204, headers=cors_headers(request))
113
 
114
  @fa_app.get("/respite/v2/node/status")
115
  def _status():
116
+ # Was returning absolute container paths (`/home/user/app/...`) to
117
+ # anyone who asked. It is a liveness probe; it does not need to tell
118
+ # a stranger where it lives on disk.
119
  return JSONResponse({
120
+ "runtime": "node",
121
  "node_bin_exists": os.path.exists(node_runtime.NODE_BIN),
122
  "backend_ready": node_runtime.node_ready(),
123
+ }, headers={"Cache-Control": "no-store"})
 
124
 
125
  @fa_app.get("/respite/v2/node/version")
126
  def _version():
 
128
  if not node:
129
  return JSONResponse({"error": "node bootstrap failed"}, status_code=500)
130
  r = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30)
131
+ return JSONResponse({"stdout": r.stdout.strip(), "code": r.returncode})
132
 
133
  @fa_app.get("/respite/v2/node/proxy/{path:path}")
134
  @fa_app.post("/respite/v2/node/proxy/{path:path}")
135
  async def _proxy(request: Request, path: str):
136
+ """Forward an allowlisted path to the Node backend on 127.0.0.1:3210,
137
+ streaming both ways (SSE included)."""
138
+ if not _is_allowed(path):
139
+ # 404, not 403: a caller should not be able to probe which paths
140
+ # exist behind the relay.
141
+ return JSONResponse(
142
+ {"error": "Not found"},
143
+ status_code=404,
144
+ headers=cors_headers(request),
145
+ )
146
  if not node_runtime.node_ready():
147
  start_ok = node_runtime.start_node_backend()
148
  if not start_ok:
149
+ return JSONResponse(
150
+ {"error": "node backend not running"},
151
+ status_code=503,
152
+ headers=cors_headers(request),
153
+ )
154
  url = f"http://127.0.0.1:{node_runtime.NODE_PORT}/{path}"
155
  if request.url.query:
156
  url += "?" + request.url.query
 
158
  k: v for k, v in request.headers.items()
159
  if k.lower() not in ("host", "connection", "content-length", "accept-encoding")
160
  }
161
+ # Forwarded deliberately, and only ever to an allowlisted path: it is
162
+ # what makes the Node app apply this caller's quota instead of treating
163
+ # them as anonymous.
164
  body = await request.body()
165
  try:
166
  client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0))
 
182
  await upstream.aclose()
183
  await client.aclose()
184
 
185
+ resp_headers.update(cors_headers(request))
186
  return StreamingResponse(
187
  stream_gen(), status_code=upstream.status_code, headers=resp_headers
188
  )
189
  except Exception as e:
190
+ _log_safe(e)
191
+ return JSONResponse(
192
+ {"error": "upstream unavailable"},
193
+ status_code=502,
194
+ headers=cors_headers(request),
195
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
+
198
+ def _log_safe(exc: Exception) -> None:
199
+ """Log the detail server-side; the caller already got a generic 502.
200
+
201
+ The old handler returned `str(e)` to whoever made the request, which for
202
+ an httpx error can include the full upstream URL and headers.
203
+ """
204
+ print(f"[node-probe] upstream error: {type(exc).__name__}", flush=True)