UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

108 lines 5.11 kB
import { config } from 'telefunc'; import { Telefunc } from 'telefunc/node'; import { hostnameFromHostHeader, isLoopbackHost } from '../loopback-host.js'; import { registerDashboardTelefunctions } from '../dashboard-rpc/register.js'; let instance; function setup() { if (instance) return instance; config.shield = { dev: false, prod: false }; config.disableNamingConvention = true; registerDashboardTelefunctions(); instance = new Telefunc(); return instance; } /** * CSRF guard for the state-changing Telefunc calls. A browser attaches an `Origin` * header to every cross-site request, so we reject any POST whose Origin is not this * same server (or a loopback host) — otherwise a page on `evil.com` could `fetch()` the * localhost dashboard and spawn/steer a run. An absent Origin means a non-browser caller * (curl, the test suite) with no ambient session to abuse, so it passes. Lives here beside * the mount, its only caller. */ export function isSameOriginRequest(req) { const origin = req.headers.origin; if (!origin) return true; const host = req.headers.host; if (host && (origin === `http://${host}` || origin === `https://${host}`)) return true; let hostname; try { hostname = new URL(origin).hostname; } catch { return false; // malformed Origin: treat as cross-origin } return isLoopbackHost(hostname); } /** * DNS-rebinding guard, the other half of the CSRF check above. A page on `evil.com` whose DNS * re-answers as `127.0.0.1` is *same-origin* with this server as far as the browser is concerned, * so its `fetch()` takes the passing branch of {@link isSameOriginRequest} — and every RPC behind * the mount, `sendStart` included, is reachable from a page the user merely visited. * * The `Host` header is what still gives the attacker away: it carries the name the browser was * asked for (`evil.com`), not the address it resolved to. So when we are bound to loopback, the * only `Host` a real user's browser can send is a loopback one (or the bound address itself) — * anything else is a rebound name and is rejected. An absent `Host` is rejected too when we are * enforcing: HTTP/1.1 requires it, and every browser sends it. * * A non-loopback bind (`--host`, #1051) is reached by a hostname we cannot predict, so there is * no allowlist to check against; that case gates behind the shared daemon token instead. Hosts * that never pass a bind host at all (the relay, which serves a public domain) are unaffected. */ export function isExpectedHost(req, boundHost) { if (boundHost === undefined || !isLoopbackHost(boundHost)) return true; const header = req.headers.host; if (!header) return false; const hostname = hostnameFromHostHeader(header); return isLoopbackHost(hostname) || hostname === boundHost; } /** * Mount the dashboard's Telefunc surface (#405) on the daemon's `node:http` server: one * `serve()` handles both the RPCs and the Channel SSE stream at `/_telefunc`. Telefunc * runs in the daemon process, so a `sendStart` telefunction can call the daemon's own * `startRun` via the request context. The `context` is exactly what each telefunction * reaches through {@link getContext} (see {@link DashboardContext}): the daemon wires the * full set, the relay passes only an events source plus an empty projects provider. Cross- * origin POSTs are rejected (CSRF: a page on evil.com must not steer or start a run), as are * requests carrying someone else's `Host` when we are bound to loopback (DNS rebinding: the * same page must not reach us by pointing its own name at `127.0.0.1`). Pass `opts.host` — the * address the server is bound to — to enable that second check; a host serving a public domain * (the relay) leaves it unset. Returns whether the request was Telefunc's. */ export function makeTelefuncMount(context = {}, opts = {}) { return async (req, res) => { if (!isSameOriginRequest(req)) { res.writeHead(403, { 'content-type': 'text/plain' }); res.end('cross-origin request forbidden'); return true; } if (!isExpectedHost(req, opts.host)) { res.writeHead(403, { 'content-type': 'text/plain' }); res.end('unexpected Host header'); return true; } const tf = setup(); // Never let a telefunc failure become an unhandled rejection that kills the daemon: // telefunc 0.2.22 throws on a bare `GET /_telefunc` (it passes the request as a body, // which `new Request()` rejects for GET), and a browser tab hits that on reconnect. try { return await tf.serve({ req, res, context: context }); } catch { if (!res.headersSent) { res.writeHead(400, { 'content-type': 'text/plain' }); res.end('bad telefunc request'); } else { res.end(); } return true; } }; } //# sourceMappingURL=telefunc-serve.js.map