Repository navigation
Expand file tree
/
Copy pathproxy.js
More file actions
164 lines (154 loc) · 7.32 KB
/
Copy pathproxy.js
File metadata and controls
164 lines (154 loc) · 7.32 KB
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
/**
* Countersign — Anthropic API proxy (Node, zero dependencies, Node 18+)
* ---------------------------------------------------------------------
* Holds the API key server-side; the app's "Team server proxy" provider posts
* Anthropic-format requests here and this forwards them.
*
* Run:
* ANTHROPIC_API_KEY=sk-ant-... node proxy.js
*
* Optional env:
* PORT=8787 listen port (default 8787)
* HOST=127.0.0.1 listen address; any other address requires
* COUNTERSIGN_TOKEN unless ALLOW_NO_TOKEN=1
* COUNTERSIGN_TOKEN=secret shared team passphrase (recommended)
* ALLOW_ORIGIN=https://you.com CORS origin if the HTML is served from a
* different host/port than this proxy
* (default: *, tighten this in production; with
* neither this nor a token set, requests from other
* origins are refused)
*
* In the app: AI settings -> "Team server proxy", URL: http://yourhost:8787/proxy
* (If you reverse-proxy it behind the same domain as the HTML, CORS is a non-issue.)
*/
const http = require("http");
const net = require("net");
const crypto = require("crypto");
const API_KEY = process.env.ANTHROPIC_API_KEY || "";
const TEAM_TOKEN = process.env.COUNTERSIGN_TOKEN || "";
const PORT = parseInt(process.env.PORT || "8787", 10);
const HOST = process.env.HOST || "127.0.0.1";
const ALLOW_NO_TOKEN = process.env.ALLOW_NO_TOKEN === "1";
const ALLOW_ORIGIN = process.env.ALLOW_ORIGIN || "*";
const ALLOWED_MODELS = ["claude-sonnet-5-5", "claude-sonnet-4-6", "claude-haiku-4-5", "claude-haiku-4-5-20251001"];
const DEFAULT_MODEL = "claude-sonnet-5-5";
// Must stay above what the app asks for on its largest call ("Extract questions"
// returns a JSON array of up to 40 items, and thinking tokens count against the cap);
// a 1024 cap silently truncated that JSON.
const MAX_TOKENS_CAP = 16384;
const MAX_BODY_BYTES = 400_000;
function send(res, code, obj) {
res.writeHead(code, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": ALLOW_ORIGIN,
"X-Content-Type-Options": "nosniff",
});
res.end(typeof obj === "string" ? obj : JSON.stringify(obj));
}
const fail = (res, code, message) =>
send(res, code, { error: { type: "proxy_error", message } });
/* Constant-time compare, to match proxy.php's hash_equals() */
/* With no team token and no ALLOW_ORIGIN, the only protection is that the port is local, and a
web page open in the operator's own browser can still reach a local port. So refuse browser
requests from other origins, and Host names that are not loopback (a DNS name rebound to
127.0.0.1). curl, scripts and same-machine tools send no Origin and are unaffected. */
const ORIGIN_CONFIGURED = !!process.env.ALLOW_ORIGIN;
const isLoopback = (h) => h === "localhost" || h === "::1" || (net.isIPv4(h) && h.startsWith("127."));
function browserRefusal(req) {
if (TEAM_TOKEN || ORIGIN_CONFIGURED) return null;
const rawHost = String(req.headers.host || "").toLowerCase();
const host = rawHost.startsWith("[") ? rawHost.slice(1, rawHost.indexOf("]")) : rawHost.split(":")[0];
if (isLoopback(HOST) && host && !isLoopback(host))
return "Host " + host + " is not allowed. Set COUNTERSIGN_TOKEN to serve other host names";
const origin = req.headers.origin;
if (origin && origin !== "http://" + req.headers.host)
return "Requests from web pages need COUNTERSIGN_TOKEN or ALLOW_ORIGIN on the proxy";
return null;
}
function tokenOk(given) {
// hash both sides: constant-time in the token's length too
const sha = (s) => crypto.createHash("sha256").update(String(s || ""), "utf8").digest();
return crypto.timingSafeEqual(sha(given), sha(TEAM_TOKEN));
}
const server = http.createServer((req, res) => {
const refusal = browserRefusal(req);
if (refusal) return fail(res, 403, refusal);
// CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": ALLOW_ORIGIN,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Team-Token",
"Access-Control-Max-Age": "86400",
});
return res.end();
}
// Accept /proxy at any mount point ("/", "/proxy", "/rfp/proxy", trailing slash)
// so an nginx location block can sit at a subpath, as proxy.php already does.
const urlPath = (req.url.split("?")[0] || "/").replace(/\/+$/, "") || "/";
if (req.method !== "POST" || !(urlPath === "/" || urlPath.endsWith("/proxy")))
return fail(res, req.method === "POST" ? 404 : 405, "POST /proxy only");
if (!API_KEY) return fail(res, 500, "Server not configured: set ANTHROPIC_API_KEY");
if (TEAM_TOKEN && !tokenOk(req.headers["x-team-token"]))
return fail(res, 401, "Missing or wrong team token");
// text/plain needs no CORS preflight; requiring JSON makes ALLOW_ORIGIN meaningful
if (!/^application\/json\b/i.test(req.headers["content-type"] || ""))
return fail(res, 415, "Send the body as Content-Type: application/json");
let size = 0;
const chunks = [];
req.on("data", (c) => {
size += c.length;
if (res.writableEnded) { // 413 already sent — drain so the client can read it,
if (size > MAX_BODY_BYTES * 8) req.destroy(); // but hard-stop a runaway upload
return;
}
if (size > MAX_BODY_BYTES) {
chunks.length = 0;
fail(res, 413, "Request too large"); // destroying here would RST before the 413 arrives
return;
}
chunks.push(c);
});
req.on("end", async () => {
if (res.writableEnded) return;
let body;
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
catch { return fail(res, 400, "Invalid JSON"); }
const m = body?.messages;
if (!Array.isArray(m) || !m.length || !m.every((x) => x && typeof x === "object" && !Array.isArray(x)))
return fail(res, 400, "Body must be Anthropic /v1/messages JSON");
const payload = {
model: ALLOWED_MODELS.includes(body.model) ? body.model : DEFAULT_MODEL,
max_tokens: Math.min(parseInt(body.max_tokens, 10) || MAX_TOKENS_CAP, MAX_TOKENS_CAP),
messages: body.messages,
};
if (typeof body.system === "string") payload.system = body.system;
try {
const upstream = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(120_000),
});
send(res, upstream.status, await upstream.text());
} catch (err) {
fail(res, 502, "Upstream request failed: " + err.message);
}
});
});
if (!isLoopback(HOST) && !TEAM_TOKEN && !ALLOW_NO_TOKEN) {
console.error(`Refusing to listen on ${HOST} without COUNTERSIGN_TOKEN: anyone who can reach the port ` +
"could spend your API key. Set COUNTERSIGN_TOKEN, or ALLOW_NO_TOKEN=1 to accept that.");
process.exit(1);
}
server.listen(PORT, HOST, () =>
console.log(
`Countersign proxy on http://${HOST}:${PORT}/proxy` +
(TEAM_TOKEN ? " (team token required)" : " (WARNING: no team token set" +
(ORIGIN_CONFIGURED ? ")" : "; browser requests from other origins are refused)"))
)
);