@gmickel/gno 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/assets/skill/SKILL.md +17 -1
- package/assets/skill/mcp-reference.md +21 -0
- package/package.json +2 -2
- package/src/cli/commands/daemon.ts +69 -2
- package/src/cli/commands/models/pull.ts +13 -3
- package/src/cli/commands/status.ts +2 -0
- package/src/cli/detach.ts +37 -20
- package/src/cli/program.ts +74 -27
- package/src/config/index.ts +3 -0
- package/src/config/types.ts +37 -0
- package/src/core/job-manager.ts +19 -0
- package/src/core/mutation-generations.ts +33 -0
- package/src/llm/cache.ts +13 -3
- package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
- package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
- package/src/mcp/context.ts +161 -0
- package/src/mcp/http-security.ts +477 -0
- package/src/mcp/http-session.ts +272 -0
- package/src/mcp/http-transport.ts +370 -0
- package/src/mcp/resources/index.ts +141 -134
- package/src/mcp/server.ts +19 -79
- package/src/mcp/tools/add-collection.ts +3 -1
- package/src/mcp/tools/capture.ts +3 -0
- package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
- package/src/mcp/tools/context.ts +9 -8
- package/src/mcp/tools/embed.ts +62 -52
- package/src/mcp/tools/index-cmd.ts +88 -74
- package/src/mcp/tools/index.ts +22 -2
- package/src/mcp/tools/remove-collection.ts +2 -0
- package/src/mcp/tools/status.ts +11 -0
- package/src/mcp/tools/sync.ts +16 -14
- package/src/mcp/tools/workspace-write.ts +7 -3
- package/src/serve/background-runtime.ts +12 -212
- package/src/serve/embed-scheduler.ts +74 -43
- package/src/serve/index.ts +9 -0
- package/src/serve/jobs.ts +78 -80
- package/src/serve/public/components/HealthCenter.tsx +74 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Dashboard.tsx +1 -0
- package/src/serve/resident-admission.ts +159 -0
- package/src/serve/resident-background-work.ts +39 -0
- package/src/serve/resident-request.ts +55 -0
- package/src/serve/resident-runtime.ts +490 -0
- package/src/serve/resident-status.ts +96 -0
- package/src/serve/routes/api.ts +263 -167
- package/src/serve/routes/mcp.ts +69 -0
- package/src/serve/server.ts +191 -37
- package/src/serve/status-model.ts +51 -0
- package/src/serve/status.ts +5 -0
- package/src/store/sqlite/adapter.ts +26 -9
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/** Fail-closed network boundary for the resident Streamable HTTP transport. */
|
|
2
|
+
|
|
3
|
+
// node:fs/promises exposes atomic create modes and POSIX metadata; Bun.file does not.
|
|
4
|
+
import { lstat, open } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
import type { HttpGatewayConfig } from "../config/types";
|
|
7
|
+
|
|
8
|
+
import { expandPath } from "../config/paths";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_HTTP_GATEWAY_HOST = "127.0.0.1";
|
|
11
|
+
export const DEFAULT_HTTP_GATEWAY_PORT = 3000;
|
|
12
|
+
export const DEFAULT_HTTP_MCP_MAX_BODY_BYTES = 1024 * 1024;
|
|
13
|
+
export const DEFAULT_HTTP_MCP_MAX_REQUESTS_PER_MINUTE = 120;
|
|
14
|
+
export const DEFAULT_HTTP_MCP_MAX_CONCURRENT_REQUESTS = 64;
|
|
15
|
+
export const DEFAULT_HTTP_MCP_MAX_QUEUED_REQUESTS = 16;
|
|
16
|
+
export const DEFAULT_HTTP_MCP_MAX_SESSIONS = 32;
|
|
17
|
+
export const DEFAULT_HTTP_MCP_SESSION_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
const MAX_RATE_KEYS = 4096;
|
|
20
|
+
const RATE_WINDOW_MS = 60_000;
|
|
21
|
+
const TOKEN_BYTES = 32;
|
|
22
|
+
const TOKEN_FILE_FORBIDDEN_MODE = 0o077;
|
|
23
|
+
const VISIBLE_ASCII = /^[\x21-\x7e]+$/;
|
|
24
|
+
|
|
25
|
+
export interface HttpMcpPeerServer {
|
|
26
|
+
requestIP(request: Request): { address: string; port: number } | null;
|
|
27
|
+
timeout(request: Request, seconds: number): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedHttpGatewayConfig {
|
|
31
|
+
host: string;
|
|
32
|
+
port: number;
|
|
33
|
+
tokenFile?: string;
|
|
34
|
+
allowedHosts: readonly string[];
|
|
35
|
+
allowedOrigins: readonly string[];
|
|
36
|
+
enableWrite: boolean;
|
|
37
|
+
limits: {
|
|
38
|
+
maxBodyBytes: number;
|
|
39
|
+
maxRequestsPerMinute: number;
|
|
40
|
+
maxConcurrentRequests: number;
|
|
41
|
+
maxQueuedRequests: number;
|
|
42
|
+
maxSessions: number;
|
|
43
|
+
sessionIdleTimeoutMs: number;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HttpGatewayOverrides {
|
|
48
|
+
host?: string;
|
|
49
|
+
port?: number;
|
|
50
|
+
tokenFile?: string;
|
|
51
|
+
allowedHosts?: string[];
|
|
52
|
+
allowedOrigins?: string[];
|
|
53
|
+
enableWrite?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AuthorizedHttpMcpRequest {
|
|
57
|
+
identity: string;
|
|
58
|
+
parsedBody?: unknown;
|
|
59
|
+
request: Request;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type HttpMcpAuthorizationResult =
|
|
63
|
+
| { ok: true; value: AuthorizedHttpMcpRequest }
|
|
64
|
+
| { ok: false; response: Response };
|
|
65
|
+
|
|
66
|
+
interface TokenState {
|
|
67
|
+
digest: string;
|
|
68
|
+
revision: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface RateState {
|
|
72
|
+
count: number;
|
|
73
|
+
windowStartedAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HttpMcpSecurityOptions {
|
|
77
|
+
now?: () => number;
|
|
78
|
+
onCredentialsChanged?: () => Promise<void> | void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function securityError(status: 401 | 403 | 413 | 429 | 503): Response {
|
|
82
|
+
const messages = {
|
|
83
|
+
401: "Unauthorized",
|
|
84
|
+
403: "Forbidden",
|
|
85
|
+
413: "Request body too large",
|
|
86
|
+
429: "Too many requests",
|
|
87
|
+
503: "Resident runtime unavailable",
|
|
88
|
+
} as const;
|
|
89
|
+
const headers = status === 401 ? { "WWW-Authenticate": "Bearer" } : undefined;
|
|
90
|
+
return Response.json(
|
|
91
|
+
{
|
|
92
|
+
jsonrpc: "2.0",
|
|
93
|
+
error: { code: -32_000, message: messages[status] },
|
|
94
|
+
id: null,
|
|
95
|
+
},
|
|
96
|
+
{ status, headers }
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isLoopbackAddress(address: string): boolean {
|
|
101
|
+
const normalized = address.toLowerCase();
|
|
102
|
+
if (normalized === "::1") return true;
|
|
103
|
+
const mapped = normalized.startsWith("::ffff:")
|
|
104
|
+
? normalized.slice("::ffff:".length)
|
|
105
|
+
: normalized;
|
|
106
|
+
const octets = mapped.split(".");
|
|
107
|
+
return (
|
|
108
|
+
octets.length === 4 &&
|
|
109
|
+
octets.every((octet) => /^\d{1,3}$/.test(octet)) &&
|
|
110
|
+
Number(octets[0]) === 127 &&
|
|
111
|
+
octets.every((octet) => Number(octet) <= 255)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isHttpGatewayLoopbackBind(host: string): boolean {
|
|
116
|
+
return host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function defaultAllowedHosts(host: string, port: number): string[] {
|
|
120
|
+
if (host === "127.0.0.1") return [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
121
|
+
if (host === "::1" || host === "[::1]") return [`[::1]:${port}`];
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function defaultAllowedOrigins(host: string, port: number): string[] {
|
|
126
|
+
return defaultAllowedHosts(host, port).map(
|
|
127
|
+
(allowedHost) => `http://${allowedHost}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateHostEntry(host: string): boolean {
|
|
132
|
+
if (host !== host.trim() || host.length === 0) return false;
|
|
133
|
+
if (host.includes("*") || host.includes(",")) return false;
|
|
134
|
+
try {
|
|
135
|
+
const url = new URL(`http://${host}`);
|
|
136
|
+
return (
|
|
137
|
+
url.host.toLowerCase() === host.toLowerCase() && url.pathname === "/"
|
|
138
|
+
);
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateOriginEntry(origin: string): boolean {
|
|
145
|
+
if (origin !== origin.trim() || origin.length === 0) return false;
|
|
146
|
+
if (origin.includes("*")) return false;
|
|
147
|
+
try {
|
|
148
|
+
const url = new URL(origin);
|
|
149
|
+
return (
|
|
150
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
151
|
+
url.username === "" &&
|
|
152
|
+
url.password === "" &&
|
|
153
|
+
url.pathname === "/" &&
|
|
154
|
+
url.search === "" &&
|
|
155
|
+
url.hash === "" &&
|
|
156
|
+
url.origin === origin
|
|
157
|
+
);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function resolveHttpGatewayConfig(
|
|
164
|
+
config: HttpGatewayConfig | undefined,
|
|
165
|
+
overrides: HttpGatewayOverrides = {}
|
|
166
|
+
): ResolvedHttpGatewayConfig {
|
|
167
|
+
const host = overrides.host ?? config?.host ?? DEFAULT_HTTP_GATEWAY_HOST;
|
|
168
|
+
const port = overrides.port ?? DEFAULT_HTTP_GATEWAY_PORT;
|
|
169
|
+
return {
|
|
170
|
+
host,
|
|
171
|
+
port,
|
|
172
|
+
tokenFile: overrides.tokenFile ?? config?.tokenFile,
|
|
173
|
+
allowedHosts:
|
|
174
|
+
overrides.allowedHosts ??
|
|
175
|
+
config?.allowedHosts ??
|
|
176
|
+
defaultAllowedHosts(host, port),
|
|
177
|
+
allowedOrigins:
|
|
178
|
+
overrides.allowedOrigins ??
|
|
179
|
+
config?.allowedOrigins ??
|
|
180
|
+
defaultAllowedOrigins(host, port),
|
|
181
|
+
enableWrite: overrides.enableWrite ?? config?.enableWrite ?? false,
|
|
182
|
+
limits: {
|
|
183
|
+
maxBodyBytes:
|
|
184
|
+
config?.limits?.maxBodyBytes ?? DEFAULT_HTTP_MCP_MAX_BODY_BYTES,
|
|
185
|
+
maxRequestsPerMinute:
|
|
186
|
+
config?.limits?.maxRequestsPerMinute ??
|
|
187
|
+
DEFAULT_HTTP_MCP_MAX_REQUESTS_PER_MINUTE,
|
|
188
|
+
maxConcurrentRequests:
|
|
189
|
+
config?.limits?.maxConcurrentRequests ??
|
|
190
|
+
DEFAULT_HTTP_MCP_MAX_CONCURRENT_REQUESTS,
|
|
191
|
+
maxQueuedRequests:
|
|
192
|
+
config?.limits?.maxQueuedRequests ??
|
|
193
|
+
DEFAULT_HTTP_MCP_MAX_QUEUED_REQUESTS,
|
|
194
|
+
maxSessions: config?.limits?.maxSessions ?? DEFAULT_HTTP_MCP_MAX_SESSIONS,
|
|
195
|
+
sessionIdleTimeoutMs:
|
|
196
|
+
config?.limits?.sessionIdleTimeoutMs ??
|
|
197
|
+
DEFAULT_HTTP_MCP_SESSION_IDLE_TIMEOUT_MS,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function sha256(value: string): Promise<string> {
|
|
203
|
+
const bytes = new TextEncoder().encode(value);
|
|
204
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
205
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function constantTimeEqual(left: string, right: string): boolean {
|
|
209
|
+
if (left.length !== right.length) return false;
|
|
210
|
+
let difference = 0;
|
|
211
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
212
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
213
|
+
}
|
|
214
|
+
return difference === 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function createTokenFile(path: string): Promise<void> {
|
|
218
|
+
const token = [...crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))]
|
|
219
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
220
|
+
.join("");
|
|
221
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
222
|
+
try {
|
|
223
|
+
handle = await open(path, "wx", 0o600);
|
|
224
|
+
await handle.writeFile(`${token}\n`, { encoding: "utf8" });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const code = (error as { code?: string }).code;
|
|
227
|
+
if (code !== "EEXIST") throw error;
|
|
228
|
+
} finally {
|
|
229
|
+
await handle?.close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function loadTokenFile(path: string): Promise<TokenState> {
|
|
234
|
+
const expandedPath = expandPath(path);
|
|
235
|
+
const pathMetadata = await lstat(expandedPath);
|
|
236
|
+
if (!pathMetadata.isFile()) throw new Error("MCP token path must be a file");
|
|
237
|
+
const handle = await open(expandedPath, "r");
|
|
238
|
+
let metadata: Awaited<ReturnType<typeof handle.stat>>;
|
|
239
|
+
let raw: string;
|
|
240
|
+
try {
|
|
241
|
+
metadata = await handle.stat();
|
|
242
|
+
if (
|
|
243
|
+
metadata.dev !== pathMetadata.dev ||
|
|
244
|
+
metadata.ino !== pathMetadata.ino ||
|
|
245
|
+
!metadata.isFile()
|
|
246
|
+
)
|
|
247
|
+
throw new Error("MCP token file changed while opening");
|
|
248
|
+
if (
|
|
249
|
+
process.platform !== "win32" &&
|
|
250
|
+
(metadata.mode & TOKEN_FILE_FORBIDDEN_MODE) !== 0
|
|
251
|
+
)
|
|
252
|
+
throw new Error("MCP token file permissions must be 0600 or stricter");
|
|
253
|
+
raw = await handle.readFile({ encoding: "utf8" });
|
|
254
|
+
} finally {
|
|
255
|
+
await handle.close();
|
|
256
|
+
}
|
|
257
|
+
const token = raw.trim();
|
|
258
|
+
if (token.length < 32 || token.length > 512 || !VISIBLE_ASCII.test(token))
|
|
259
|
+
throw new Error("MCP token file contains an invalid token");
|
|
260
|
+
const digest = await sha256(token);
|
|
261
|
+
return {
|
|
262
|
+
digest,
|
|
263
|
+
revision: `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}:${digest}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Create the explicitly requested token file, then validate restrictive mode. */
|
|
268
|
+
export async function ensureHttpMcpTokenFile(path: string): Promise<void> {
|
|
269
|
+
const expandedPath = expandPath(path);
|
|
270
|
+
await createTokenFile(expandedPath);
|
|
271
|
+
await loadTokenFile(expandedPath);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function readBoundedJson(
|
|
275
|
+
request: Request,
|
|
276
|
+
maxBytes: number
|
|
277
|
+
): Promise<{ ok: true; value: unknown } | { ok: false; response: Response }> {
|
|
278
|
+
const declaredLength = request.headers.get("content-length");
|
|
279
|
+
if (declaredLength !== null) {
|
|
280
|
+
if (!/^\d+$/.test(declaredLength))
|
|
281
|
+
return { ok: false, response: securityError(413) };
|
|
282
|
+
if (Number(declaredLength) > maxBytes)
|
|
283
|
+
return { ok: false, response: securityError(413) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const reader = request.body?.getReader();
|
|
287
|
+
if (!reader) return { ok: true, value: undefined };
|
|
288
|
+
const chunks: Uint8Array[] = [];
|
|
289
|
+
let total = 0;
|
|
290
|
+
while (true) {
|
|
291
|
+
const result = await reader.read();
|
|
292
|
+
if (result.done) break;
|
|
293
|
+
total += result.value.byteLength;
|
|
294
|
+
if (total > maxBytes) {
|
|
295
|
+
await reader.cancel();
|
|
296
|
+
return { ok: false, response: securityError(413) };
|
|
297
|
+
}
|
|
298
|
+
chunks.push(result.value);
|
|
299
|
+
}
|
|
300
|
+
const body = new Uint8Array(total);
|
|
301
|
+
let offset = 0;
|
|
302
|
+
for (const chunk of chunks) {
|
|
303
|
+
body.set(chunk, offset);
|
|
304
|
+
offset += chunk.byteLength;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
return { ok: true, value: JSON.parse(new TextDecoder().decode(body)) };
|
|
308
|
+
} catch {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
response: Response.json(
|
|
312
|
+
{
|
|
313
|
+
jsonrpc: "2.0",
|
|
314
|
+
error: { code: -32_700, message: "Parse error: Invalid JSON" },
|
|
315
|
+
id: null,
|
|
316
|
+
},
|
|
317
|
+
{ status: 400 }
|
|
318
|
+
),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export class HttpMcpSecurity {
|
|
324
|
+
readonly #config: ResolvedHttpGatewayConfig;
|
|
325
|
+
readonly #now: () => number;
|
|
326
|
+
readonly #onCredentialsChanged: () => Promise<void> | void;
|
|
327
|
+
readonly #rates = new Map<string, RateState>();
|
|
328
|
+
#tokenState?: TokenState;
|
|
329
|
+
|
|
330
|
+
constructor(
|
|
331
|
+
config: ResolvedHttpGatewayConfig,
|
|
332
|
+
options: HttpMcpSecurityOptions = {}
|
|
333
|
+
) {
|
|
334
|
+
this.#config = config;
|
|
335
|
+
this.#now = options.now ?? Date.now;
|
|
336
|
+
this.#onCredentialsChanged = options.onCredentialsChanged ?? (() => {});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
get config(): ResolvedHttpGatewayConfig {
|
|
340
|
+
return this.#config;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async initialize(): Promise<void> {
|
|
344
|
+
if (!this.#config.allowedHosts.every(validateHostEntry))
|
|
345
|
+
throw new Error("MCP allowedHosts must contain exact Host values");
|
|
346
|
+
if (!this.#config.allowedOrigins.every(validateOriginEntry))
|
|
347
|
+
throw new Error("MCP allowedOrigins must contain exact HTTP(S) origins");
|
|
348
|
+
|
|
349
|
+
const loopback = isHttpGatewayLoopbackBind(this.#config.host);
|
|
350
|
+
if (
|
|
351
|
+
!loopback &&
|
|
352
|
+
(!this.#config.tokenFile ||
|
|
353
|
+
this.#config.allowedHosts.length === 0 ||
|
|
354
|
+
this.#config.allowedOrigins.length === 0)
|
|
355
|
+
) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
"Wildcard/non-loopback MCP binding requires tokenFile plus exact allowedHosts and allowedOrigins"
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (this.#config.tokenFile) {
|
|
362
|
+
await ensureHttpMcpTokenFile(this.#config.tokenFile);
|
|
363
|
+
this.#tokenState = await loadTokenFile(this.#config.tokenFile);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async authorize(
|
|
368
|
+
request: Request,
|
|
369
|
+
server: HttpMcpPeerServer
|
|
370
|
+
): Promise<HttpMcpAuthorizationResult> {
|
|
371
|
+
const peer = server.requestIP(request);
|
|
372
|
+
if (!peer) return { ok: false, response: securityError(403) };
|
|
373
|
+
if (
|
|
374
|
+
isHttpGatewayLoopbackBind(this.#config.host) &&
|
|
375
|
+
!isLoopbackAddress(peer.address)
|
|
376
|
+
)
|
|
377
|
+
return { ok: false, response: securityError(403) };
|
|
378
|
+
|
|
379
|
+
const host = request.headers.get("host");
|
|
380
|
+
if (
|
|
381
|
+
!host ||
|
|
382
|
+
!this.#config.allowedHosts.some(
|
|
383
|
+
(allowedHost) => allowedHost.toLowerCase() === host.toLowerCase()
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
return { ok: false, response: securityError(403) };
|
|
387
|
+
|
|
388
|
+
const origin = request.headers.get("origin");
|
|
389
|
+
if (origin && !this.#config.allowedOrigins.includes(origin))
|
|
390
|
+
return { ok: false, response: securityError(403) };
|
|
391
|
+
|
|
392
|
+
if (!this.#allowRate(peer.address))
|
|
393
|
+
return { ok: false, response: securityError(429) };
|
|
394
|
+
|
|
395
|
+
const requiresAuth =
|
|
396
|
+
!isHttpGatewayLoopbackBind(this.#config.host) ||
|
|
397
|
+
this.#config.tokenFile !== undefined;
|
|
398
|
+
const tokenState = await this.#refreshTokenState();
|
|
399
|
+
let identity = "loopback";
|
|
400
|
+
if (requiresAuth) {
|
|
401
|
+
if (!tokenState) return { ok: false, response: securityError(503) };
|
|
402
|
+
const authorization = request.headers.get("authorization");
|
|
403
|
+
const match = authorization?.match(/^Bearer ([\x21-\x7e]+)$/);
|
|
404
|
+
if (!match) return { ok: false, response: securityError(401) };
|
|
405
|
+
const presentedDigest = await sha256(match[1] ?? "");
|
|
406
|
+
if (!constantTimeEqual(presentedDigest, tokenState.digest))
|
|
407
|
+
return { ok: false, response: securityError(401) };
|
|
408
|
+
identity = tokenState.digest;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let parsedBody: unknown;
|
|
412
|
+
if (request.method === "POST") {
|
|
413
|
+
const body = await readBoundedJson(
|
|
414
|
+
request,
|
|
415
|
+
this.#config.limits.maxBodyBytes
|
|
416
|
+
);
|
|
417
|
+
if (!body.ok) return body;
|
|
418
|
+
parsedBody = body.value;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const sanitizedHeaders = new Headers(request.headers);
|
|
422
|
+
sanitizedHeaders.delete("authorization");
|
|
423
|
+
sanitizedHeaders.delete("forwarded");
|
|
424
|
+
sanitizedHeaders.delete("content-length");
|
|
425
|
+
sanitizedHeaders.delete("transfer-encoding");
|
|
426
|
+
const forwardedHeaderNames: string[] = [];
|
|
427
|
+
for (const name of sanitizedHeaders.keys())
|
|
428
|
+
if (name.startsWith("x-forwarded-")) forwardedHeaderNames.push(name);
|
|
429
|
+
for (const name of forwardedHeaderNames) sanitizedHeaders.delete(name);
|
|
430
|
+
const sanitizedRequest = new Request(request.url, {
|
|
431
|
+
method: request.method,
|
|
432
|
+
headers: sanitizedHeaders,
|
|
433
|
+
signal: request.signal,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
ok: true,
|
|
438
|
+
value: { identity, parsedBody, request: sanitizedRequest },
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#allowRate(key: string): boolean {
|
|
443
|
+
const now = this.#now();
|
|
444
|
+
let state = this.#rates.get(key);
|
|
445
|
+
if (!state || now - state.windowStartedAt >= RATE_WINDOW_MS) {
|
|
446
|
+
state = { count: 0, windowStartedAt: now };
|
|
447
|
+
this.#rates.set(key, state);
|
|
448
|
+
}
|
|
449
|
+
state.count += 1;
|
|
450
|
+
if (this.#rates.size > MAX_RATE_KEYS) {
|
|
451
|
+
for (const [rateKey, candidate] of this.#rates) {
|
|
452
|
+
if (now - candidate.windowStartedAt >= RATE_WINDOW_MS)
|
|
453
|
+
this.#rates.delete(rateKey);
|
|
454
|
+
}
|
|
455
|
+
if (this.#rates.size > MAX_RATE_KEYS) this.#rates.clear();
|
|
456
|
+
}
|
|
457
|
+
return state.count <= this.#config.limits.maxRequestsPerMinute;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async #refreshTokenState(): Promise<TokenState | undefined> {
|
|
461
|
+
if (!this.#config.tokenFile) return undefined;
|
|
462
|
+
let next: TokenState | undefined;
|
|
463
|
+
try {
|
|
464
|
+
next = await loadTokenFile(this.#config.tokenFile);
|
|
465
|
+
} catch {
|
|
466
|
+
if (this.#tokenState) {
|
|
467
|
+
this.#tokenState = undefined;
|
|
468
|
+
await this.#onCredentialsChanged();
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
if (this.#tokenState && this.#tokenState.revision !== next.revision)
|
|
473
|
+
await this.#onCredentialsChanged();
|
|
474
|
+
this.#tokenState = next;
|
|
475
|
+
return next;
|
|
476
|
+
}
|
|
477
|
+
}
|