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
JavaScript
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