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)