@tonyclaw/agent-inspector 3.0.26 → 3.0.27
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/.output/cli.js +80 -11
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-SZRUDQc9.js → CompareDrawer-BVCAAbbu.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-BJvwOrSi.js → ProxyViewerContainer-CYOXB81n.js} +200 -200
- package/.output/public/assets/{ReplayDialog-DgfmqiM3.js → ReplayDialog-DMX23O5G.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-CS6c7Qfn.js → RequestAnatomy-S17pYDRc.js} +1 -1
- package/.output/public/assets/{ResponseView-C0Ri_5gm.js → ResponseView-Cf1XrawD.js} +2 -2
- package/.output/public/assets/{StreamingChunkSequence-7dYcHWyZ.js → StreamingChunkSequence-BdOrVlCi.js} +1 -1
- package/.output/public/assets/{_sessionId-D-V84cgw.js → _sessionId-BnKGmJrR.js} +1 -1
- package/.output/public/assets/{index-DgFWCxOs.js → index-9fMLCU4d.js} +1 -1
- package/.output/public/assets/{index-CSIL7-MK.js → index-B1atk19i.js} +2 -2
- package/.output/public/assets/{index-DrVJsks2.js → index-gY3I-7-D.js} +1 -1
- package/.output/public/assets/{json-viewer-C9Ji3gYN.js → json-viewer-CpyYpPjx.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +6 -6
- package/.output/server/{_sessionId-BQ6YQOO1.mjs → _sessionId-eA_h2_s6.mjs} +4 -2
- package/.output/server/_ssr/{CompareDrawer-CqNzIeMR.mjs → CompareDrawer-K2k9G7r1.mjs} +5 -3
- package/.output/server/_ssr/{ProxyViewerContainer-CMC7Vsbw.mjs → ProxyViewerContainer-CbyBpCAj.mjs} +27 -60
- package/.output/server/_ssr/{ReplayDialog-D9p_LaTr.mjs → ReplayDialog-DacI2Iv0.mjs} +5 -3
- package/.output/server/_ssr/{RequestAnatomy-B4ge3qNZ.mjs → RequestAnatomy-gh-pAdkO.mjs} +4 -2
- package/.output/server/_ssr/{ResponseView-BCqMKApI.mjs → ResponseView-BRJKN50-.mjs} +5 -3
- package/.output/server/_ssr/{StreamingChunkSequence-BA4zTLE4.mjs → StreamingChunkSequence-BQLVEuWY.mjs} +4 -2
- package/.output/server/_ssr/{index-CzxhDjlq.mjs → index-DuSWrjbH.mjs} +4 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-B3qV6iEJ.mjs → json-viewer-DNs1ZjKI.mjs} +4 -2
- package/.output/server/_ssr/{router-CZe_R31M.mjs → router-Dgx6-ot3.mjs} +712 -217
- package/.output/server/{_tanstack-start-manifest_v-B50VTLrH.mjs → _tanstack-start-manifest_v-CoGjnkQD.mjs} +1 -1
- package/.output/server/index.mjs +64 -64
- package/README.md +8 -0
- package/package.json +4 -5
- package/src/cli.ts +43 -9
- package/src/components/providers/ProviderCard.tsx +3 -36
- package/src/components/providers/ProviderForm.tsx +13 -12
- package/src/components/providers/ProvidersPanel.tsx +2 -2
- package/src/components/providers/SettingsDialog.tsx +3 -3
- package/src/components/proxy-viewer/ProviderLogoStack.tsx +0 -3
- package/src/contracts/anthropic.ts +0 -5
- package/src/contracts/index.ts +0 -4
- package/src/contracts/log.ts +0 -1
- package/src/contracts/openai.ts +0 -3
- package/src/knowledge/openclawClient.ts +22 -9
- package/src/knowledge/openclawGatewayClient.ts +20 -13
- package/src/lib/alertContract.ts +0 -3
- package/src/lib/ecosystemContract.ts +0 -3
- package/src/lib/export-logs.ts +1 -4
- package/src/lib/safeFetch.ts +100 -0
- package/src/lib/ssrfGuard.ts +133 -0
- package/src/lib/stopReason.ts +1 -4
- package/src/lib/unknown.ts +27 -0
- package/src/lib/useProviders.ts +1 -1
- package/src/mcp/mode.ts +3 -9
- package/src/mcp/server.ts +5 -14
- package/src/mcp/toolHandlers.ts +45 -54
- package/src/proxy/constants.ts +17 -0
- package/src/proxy/formats/anthropic/handler.ts +5 -1
- package/src/proxy/formats/anthropic/schemas.ts +1 -7
- package/src/proxy/formats/index.ts +1 -1
- package/src/proxy/formats/openai/schemas.ts +0 -3
- package/src/proxy/formats/registry.ts +0 -3
- package/src/proxy/handler.ts +91 -12
- package/src/proxy/identityProxy.ts +74 -1
- package/src/proxy/logImporter.ts +82 -12
- package/src/proxy/logIndex.ts +1 -4
- package/src/proxy/providerSecretStore.ts +181 -0
- package/src/proxy/providers.ts +84 -11
- package/src/proxy/schemas.ts +1 -8
- package/src/proxy/sqliteLogIndex.ts +0 -16
- package/src/proxy/store.ts +1 -5
- package/src/proxy/toolSchemaWarnings.ts +1 -9
- package/src/routes/api/logs.$id.replay.ts +17 -5
- package/src/routes/api/providers.$providerId.model-metadata.ts +11 -1
- package/src/routes/api/providers.$providerId.ts +18 -5
- package/src/routes/api/providers.ts +18 -7
package/src/proxy/constants.ts
CHANGED
|
@@ -26,12 +26,29 @@ export const HEADER_HOST = "host";
|
|
|
26
26
|
// Auth header types
|
|
27
27
|
export const AUTH_HEADER_X_API_KEY = "x-api-key";
|
|
28
28
|
|
|
29
|
+
// Headers whose values contain secrets (API keys, bearer tokens).
|
|
30
|
+
// These are redacted before being written to logs when captureMode is "full".
|
|
31
|
+
export const SENSITIVE_HEADERS = new Set([
|
|
32
|
+
HEADER_AUTHORIZATION,
|
|
33
|
+
HEADER_X_API_KEY,
|
|
34
|
+
"cookie",
|
|
35
|
+
"set-cookie",
|
|
36
|
+
"proxy-authorization",
|
|
37
|
+
]);
|
|
38
|
+
|
|
29
39
|
// Content types
|
|
30
40
|
export const CONTENT_TYPE_EVENT_STREAM = "text/event-stream";
|
|
31
41
|
|
|
32
42
|
// Response status
|
|
33
43
|
export const STATUS_FORBIDDEN = 403;
|
|
34
44
|
export const STATUS_BAD_GATEWAY = 502;
|
|
45
|
+
export const STATUS_PAYLOAD_TOO_LARGE = 413;
|
|
46
|
+
|
|
47
|
+
// Maximum request body size accepted by the proxy (50 MB).
|
|
48
|
+
// Legitimate LLM API requests (system prompts, conversation history, tool
|
|
49
|
+
// definitions) are well under this limit; larger payloads are almost certainly
|
|
50
|
+
// malicious or malformed.
|
|
51
|
+
export const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
|
|
35
52
|
|
|
36
53
|
// Standard/safe headers to preserve when forwarding to upstream.
|
|
37
54
|
// NOTE: Authorization is intentionally excluded to prevent client auth tokens from leaking.
|
|
@@ -13,7 +13,11 @@ export const AnthropicFormatHandler: FormatHandler = {
|
|
|
13
13
|
if (result.success) {
|
|
14
14
|
return {
|
|
15
15
|
model: result.data.model,
|
|
16
|
-
sessionId:
|
|
16
|
+
sessionId:
|
|
17
|
+
result.data.metadata?.user_id ??
|
|
18
|
+
headers?.get("x-agent-inspector-session-id") ??
|
|
19
|
+
headers?.get("x-session-affinity") ??
|
|
20
|
+
null,
|
|
17
21
|
};
|
|
18
22
|
}
|
|
19
23
|
return null;
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
export {
|
|
2
2
|
AnthropicRequestSchema,
|
|
3
3
|
AnthropicResponseSchema,
|
|
4
|
-
InspectorRequestSchema,
|
|
5
|
-
InspectorResponseSchema,
|
|
6
4
|
SseEventSchema,
|
|
7
5
|
} from "../../../contracts/anthropic";
|
|
8
|
-
export type {
|
|
9
|
-
AnthropicResponse,
|
|
10
|
-
InspectorResponse,
|
|
11
|
-
ResponseContentBlockType,
|
|
12
|
-
} from "../../../contracts/anthropic";
|
|
6
|
+
export type { AnthropicResponse, ResponseContentBlockType } from "../../../contracts/anthropic";
|
|
@@ -9,5 +9,5 @@ import "./openai/zhipuProvider";
|
|
|
9
9
|
import "./openai/alibabaProvider";
|
|
10
10
|
|
|
11
11
|
export type { FormatHandler, ParsedRequest } from "./handler";
|
|
12
|
-
export { apiFormatForPath, formatRegistry, formatForPath
|
|
12
|
+
export { apiFormatForPath, formatRegistry, formatForPath } from "./registry";
|
|
13
13
|
export { registry } from "./providers";
|
|
@@ -72,9 +72,6 @@ export function apiFormatForPath(path: string): ApiFormat {
|
|
|
72
72
|
return formatRegistry.getApiFormatByPath(path) ?? "unknown";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Backward-compatible alias for older internal imports. */
|
|
76
|
-
export const requestFormatForPath = apiFormatForPath;
|
|
77
|
-
|
|
78
75
|
// Register known paths
|
|
79
76
|
formatRegistry.registerPath(PATH_V1_MESSAGES, "anthropic");
|
|
80
77
|
formatRegistry.registerPath(PATH_V1_CHAT_COMPLETIONS, "openai");
|
package/src/proxy/handler.ts
CHANGED
|
@@ -15,12 +15,19 @@ import {
|
|
|
15
15
|
HEADER_CONTENT_ENCODING,
|
|
16
16
|
HEADER_CONTENT_LENGTH,
|
|
17
17
|
CONTENT_TYPE_EVENT_STREAM,
|
|
18
|
+
SENSITIVE_HEADERS,
|
|
18
19
|
STATUS_FORBIDDEN,
|
|
19
20
|
STATUS_BAD_GATEWAY,
|
|
21
|
+
STATUS_PAYLOAD_TOO_LARGE,
|
|
22
|
+
MAX_REQUEST_BODY_BYTES,
|
|
20
23
|
} from "./constants";
|
|
21
24
|
import { getConfig } from "./config";
|
|
25
|
+
import { safeFetch } from "../lib/safeFetch";
|
|
26
|
+
|
|
27
|
+
const ALLOW_LOOPBACK = process.env["AGENT_INSPECTOR_ALLOW_LOOPBACK"] === "1";
|
|
22
28
|
import { stripClaudeCodeBillingHeader } from "./claudeCodeStrip";
|
|
23
29
|
import { stripOpenAIOrphanToolMessages } from "./openaiOrphanToolStrip";
|
|
30
|
+
import { maskApiKey } from "../lib/mask";
|
|
24
31
|
import { buildFileLogEntry, type FinalizeLogJob } from "./logFinalizer";
|
|
25
32
|
import { enqueueFinalizeLogJob } from "./sessionRuntime";
|
|
26
33
|
import { RawStreamCapture } from "./rawStreamCapture";
|
|
@@ -49,7 +56,8 @@ function buildProxyHeaders(
|
|
|
49
56
|
|
|
50
57
|
if (rawHeaders !== undefined) {
|
|
51
58
|
originalHeaders.forEach((value, key) => {
|
|
52
|
-
|
|
59
|
+
const lowerKey = key.toLowerCase();
|
|
60
|
+
rawHeaders[lowerKey] = SENSITIVE_HEADERS.has(lowerKey) ? maskApiKey(value) : value;
|
|
53
61
|
});
|
|
54
62
|
}
|
|
55
63
|
|
|
@@ -91,6 +99,50 @@ function errorMessage(err: unknown): string {
|
|
|
91
99
|
return err instanceof Error ? err.message : String(err);
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
type BodyReadResult = { ok: true; body: string | null } | { ok: false; reason: "too-large" };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads the request body as text, enforcing a maximum byte limit to prevent
|
|
106
|
+
* memory exhaustion from oversized payloads. Returns the body string, or null
|
|
107
|
+
* if the request has no body.
|
|
108
|
+
*/
|
|
109
|
+
async function readBodyWithLimit(req: Request, limitBytes: number): Promise<BodyReadResult> {
|
|
110
|
+
if (req.body === null || req.method === "GET" || req.method === "HEAD") {
|
|
111
|
+
return { ok: true, body: null };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fast path: if Content-Length is present and exceeds the limit, reject
|
|
115
|
+
// without reading any bytes.
|
|
116
|
+
const contentLengthHeader = req.headers.get(HEADER_CONTENT_LENGTH);
|
|
117
|
+
if (contentLengthHeader !== null) {
|
|
118
|
+
const declared = Number.parseInt(contentLengthHeader, 10);
|
|
119
|
+
if (Number.isFinite(declared) && declared > limitBytes) {
|
|
120
|
+
return { ok: false, reason: "too-large" };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const reader = req.body.getReader();
|
|
125
|
+
const decoder = new TextDecoder();
|
|
126
|
+
const chunks: string[] = [];
|
|
127
|
+
let totalBytes = 0;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
for (;;) {
|
|
131
|
+
const { done, value } = await reader.read();
|
|
132
|
+
if (done) break;
|
|
133
|
+
totalBytes += value.byteLength;
|
|
134
|
+
if (totalBytes > limitBytes) {
|
|
135
|
+
return { ok: false, reason: "too-large" };
|
|
136
|
+
}
|
|
137
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
138
|
+
}
|
|
139
|
+
chunks.push(decoder.decode());
|
|
140
|
+
return { ok: true, body: chunks.join("") };
|
|
141
|
+
} finally {
|
|
142
|
+
reader.releaseLock();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
94
146
|
function scheduleLogFinalization(job: FinalizeLogJob): void {
|
|
95
147
|
void enqueueFinalizeLogJob(job).catch((err) => {
|
|
96
148
|
logger.error(
|
|
@@ -230,11 +282,16 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
230
282
|
const url = new URL(req.url);
|
|
231
283
|
const parsed = parseRequestPath(req, url);
|
|
232
284
|
|
|
233
|
-
// Read body
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
285
|
+
// Read body with a size limit to prevent memory exhaustion from oversized
|
|
286
|
+
// payloads. The limit is generous (50 MB) — legitimate LLM API requests are
|
|
287
|
+
// well under this, but a malicious client could send a multi-GB body to OOM
|
|
288
|
+
// the proxy.
|
|
289
|
+
const bodyRead = await readBodyWithLimit(req, MAX_REQUEST_BODY_BYTES);
|
|
290
|
+
if (!bodyRead.ok) {
|
|
291
|
+
logger.warn(`[handler] Rejected oversized request body: ${req.method} ${parsed.apiPath}`);
|
|
292
|
+
return new Response("Request body too large", { status: STATUS_PAYLOAD_TOO_LARGE });
|
|
237
293
|
}
|
|
294
|
+
const requestBody = bodyRead.body;
|
|
238
295
|
|
|
239
296
|
// Opt-in: strip Claude Code's synthetic billing-header text block from the
|
|
240
297
|
// system prompt. Only runs for Anthropic-format requests (those whose path
|
|
@@ -284,6 +341,7 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
284
341
|
const route = describeApiRoute(parsed.apiPath);
|
|
285
342
|
const upstreamBase = selectUpstreamBase(route, matchedProviderConfig);
|
|
286
343
|
const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
|
|
344
|
+
|
|
287
345
|
const startTime = Date.now();
|
|
288
346
|
|
|
289
347
|
const captureFullDetails = config.captureMode === "full";
|
|
@@ -324,7 +382,8 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
324
382
|
: undefined;
|
|
325
383
|
if (upstreamHeadersObj !== undefined) {
|
|
326
384
|
upstreamHeaders.forEach((value, key) => {
|
|
327
|
-
|
|
385
|
+
const lowerKey = key.toLowerCase();
|
|
386
|
+
upstreamHeadersObj[lowerKey] = SENSITIVE_HEADERS.has(lowerKey) ? maskApiKey(value) : value;
|
|
328
387
|
});
|
|
329
388
|
}
|
|
330
389
|
const log = await createLog(
|
|
@@ -343,12 +402,32 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
343
402
|
|
|
344
403
|
let upstreamRes: Response;
|
|
345
404
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
405
|
+
const fetchResult = await safeFetch(
|
|
406
|
+
upstreamUrl,
|
|
407
|
+
{
|
|
408
|
+
method: req.method,
|
|
409
|
+
headers: upstreamHeaders,
|
|
410
|
+
body: bodyToForward,
|
|
411
|
+
signal: req.signal,
|
|
412
|
+
},
|
|
413
|
+
{ allowLoopback: ALLOW_LOOPBACK },
|
|
414
|
+
);
|
|
415
|
+
if (!fetchResult.ok) {
|
|
416
|
+
const status = fetchResult.kind === "network-policy" ? STATUS_FORBIDDEN : STATUS_BAD_GATEWAY;
|
|
417
|
+
logger.warn(`[handler] Safe fetch blocked ${upstreamUrl}: ${fetchResult.reason}`);
|
|
418
|
+
log.elapsedMs = Date.now() - startTime;
|
|
419
|
+
log.responseStatus = status;
|
|
420
|
+
log.responseText = fetchResult.reason;
|
|
421
|
+
appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: fetchResult.reason });
|
|
422
|
+
finalizeLogUpdate(log);
|
|
423
|
+
return new Response(
|
|
424
|
+
fetchResult.kind === "network-policy"
|
|
425
|
+
? "Forbidden: upstream URL is not permitted"
|
|
426
|
+
: "Proxy error: upstream redirects are not permitted",
|
|
427
|
+
{ status },
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
upstreamRes = fetchResult.response;
|
|
352
431
|
} catch (err) {
|
|
353
432
|
log.elapsedMs = Date.now() - startTime;
|
|
354
433
|
// Check if it was a client abort (not a proxy error)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
4
|
import { lookupClientByPort } from "./socketTracker";
|
|
4
5
|
|
|
5
6
|
const IDENTITY_HEADERS = {
|
|
@@ -35,12 +36,63 @@ const RESPONSE_HOP_BY_HOP = new Set([
|
|
|
35
36
|
export type IdentityProxyOptions = {
|
|
36
37
|
/** Public port to listen on. Clients point at this. */
|
|
37
38
|
listenPort: number;
|
|
39
|
+
/** Interface exposed to clients. Defaults are chosen by the CLI. */
|
|
40
|
+
listenHost: string;
|
|
38
41
|
/** Host the upstream TanStack Start server listens on. */
|
|
39
42
|
upstreamHost: string;
|
|
40
43
|
/** Port the upstream TanStack Start server listens on. */
|
|
41
44
|
upstreamPort: number;
|
|
45
|
+
/** Restrict non-proxy routes for clients outside the local machine. */
|
|
46
|
+
restrictRemoteControl?: boolean;
|
|
47
|
+
/** Explicitly expose UI, REST, and MCP to remote peers. */
|
|
48
|
+
allowRemoteControl?: boolean;
|
|
49
|
+
/** Optional Bearer token for remote control-plane API clients. */
|
|
50
|
+
controlToken?: string;
|
|
42
51
|
};
|
|
43
52
|
|
|
53
|
+
function isLoopbackPeer(address: string | undefined): boolean {
|
|
54
|
+
if (address === undefined) return false;
|
|
55
|
+
const normalized = address.toLowerCase();
|
|
56
|
+
return (
|
|
57
|
+
normalized === "::1" || normalized.startsWith("127.") || normalized.startsWith("::ffff:127.")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bearerToken(authorization: string | undefined): string | null {
|
|
62
|
+
if (authorization === undefined) return null;
|
|
63
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
64
|
+
return match?.[1] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tokensMatch(received: string | null, expected: string | undefined): boolean {
|
|
68
|
+
if (received === null || expected === undefined || expected === "") return false;
|
|
69
|
+
const receivedBuffer = Buffer.from(received);
|
|
70
|
+
const expectedBuffer = Buffer.from(expected);
|
|
71
|
+
return (
|
|
72
|
+
receivedBuffer.byteLength === expectedBuffer.byteLength &&
|
|
73
|
+
timingSafeEqual(receivedBuffer, expectedBuffer)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isIdentityProxyRequestAllowed(input: {
|
|
78
|
+
path: string;
|
|
79
|
+
remoteAddress?: string;
|
|
80
|
+
authorization?: string;
|
|
81
|
+
options: Pick<
|
|
82
|
+
IdentityProxyOptions,
|
|
83
|
+
"restrictRemoteControl" | "allowRemoteControl" | "controlToken"
|
|
84
|
+
>;
|
|
85
|
+
}): boolean {
|
|
86
|
+
if (input.options.restrictRemoteControl !== true) return true;
|
|
87
|
+
if (input.options.allowRemoteControl === true) return true;
|
|
88
|
+
if (isLoopbackPeer(input.remoteAddress)) return true;
|
|
89
|
+
|
|
90
|
+
const pathname = new URL(input.path, "http://localhost").pathname;
|
|
91
|
+
if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return true;
|
|
92
|
+
if (pathname === "/api/health") return true;
|
|
93
|
+
return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
|
|
94
|
+
}
|
|
95
|
+
|
|
44
96
|
/**
|
|
45
97
|
* Start a tiny native Node HTTP server that:
|
|
46
98
|
* 1. Reads the client connection's source port (`req.socket.remotePort`).
|
|
@@ -88,7 +140,7 @@ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.
|
|
|
88
140
|
});
|
|
89
141
|
});
|
|
90
142
|
server.once("error", reject);
|
|
91
|
-
server.listen(options.listenPort, () => {
|
|
143
|
+
server.listen(options.listenPort, options.listenHost, () => {
|
|
92
144
|
server.removeListener("error", reject);
|
|
93
145
|
resolve(server);
|
|
94
146
|
});
|
|
@@ -100,6 +152,27 @@ async function handleRequest(
|
|
|
100
152
|
res: http.ServerResponse,
|
|
101
153
|
options: IdentityProxyOptions,
|
|
102
154
|
): Promise<void> {
|
|
155
|
+
if (
|
|
156
|
+
!isIdentityProxyRequestAllowed({
|
|
157
|
+
path: req.url ?? "/",
|
|
158
|
+
remoteAddress: req.socket.remoteAddress,
|
|
159
|
+
authorization: req.headers.authorization,
|
|
160
|
+
options,
|
|
161
|
+
})
|
|
162
|
+
) {
|
|
163
|
+
res.writeHead(403, {
|
|
164
|
+
"content-type": "application/json; charset=utf-8",
|
|
165
|
+
"cache-control": "no-store",
|
|
166
|
+
});
|
|
167
|
+
res.end(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
error:
|
|
170
|
+
"Remote control-plane access is disabled. Use the proxy endpoint or connect locally.",
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
103
176
|
// The connection-time lookup already populated the per-port cache; this
|
|
104
177
|
// call now hits the cache and returns the result synchronously.
|
|
105
178
|
const remotePort = req.socket.remotePort;
|
package/src/proxy/logImporter.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
2
3
|
import JSZip from "jszip";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import {
|
|
@@ -14,6 +15,17 @@ import { analyzeToolSchemaWarnings } from "./toolSchemaWarnings";
|
|
|
14
15
|
|
|
15
16
|
export type ImportedLogDraft = Omit<CapturedLog, "id">;
|
|
16
17
|
|
|
18
|
+
// Maximum compressed size for a ZIP import (200 MB). Legitimate exports are
|
|
19
|
+
// well under this; anything larger is almost certainly malicious.
|
|
20
|
+
const MAX_ZIP_COMPRESSED_BYTES = 200 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
// Maximum total decompressed bytes within a single ZIP (500 MB). Prevents a
|
|
23
|
+
// small "ZIP bomb" archive (a few MB compressed → hundreds of GB decompressed)
|
|
24
|
+
// from exhausting memory. The ratio is checked against the compressed size of
|
|
25
|
+
// the *archive*, not per-entry, since the archive size is the only hard cap
|
|
26
|
+
// available before decompression begins.
|
|
27
|
+
const MAX_ZIP_DECOMPRESSED_BYTES = 500 * 1024 * 1024;
|
|
28
|
+
|
|
17
29
|
export type ParsedLogImport =
|
|
18
30
|
| {
|
|
19
31
|
ok: true;
|
|
@@ -288,13 +300,31 @@ function legacyDraftFromParts(input: {
|
|
|
288
300
|
});
|
|
289
301
|
}
|
|
290
302
|
|
|
291
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Reads a file from the ZIP as UTF-8 text. Decrements `remaining.bytes` by the
|
|
305
|
+
* decoded byte size; callers check `remaining.bytes < 0` after the call to
|
|
306
|
+
* detect that the cumulative decompressed size has blown the budget (a
|
|
307
|
+
* zip-bomb signal). We do NOT throw on budget exceeded because the project's
|
|
308
|
+
* lint rules forbid throwing inside non-CLI modules — using a mutable
|
|
309
|
+
* counter keeps the budget check declarative at every call site.
|
|
310
|
+
*/
|
|
311
|
+
async function readZipText(
|
|
312
|
+
zip: JSZip,
|
|
313
|
+
path: string,
|
|
314
|
+
remaining: { bytes: number },
|
|
315
|
+
): Promise<string | null> {
|
|
292
316
|
const file = zip.file(path);
|
|
293
|
-
|
|
317
|
+
if (file === null) return null;
|
|
318
|
+
const text = await file.async("text");
|
|
319
|
+
remaining.bytes -= Buffer.byteLength(text, "utf8");
|
|
320
|
+
return text;
|
|
294
321
|
}
|
|
295
322
|
|
|
296
|
-
async function readManifest(
|
|
297
|
-
|
|
323
|
+
async function readManifest(
|
|
324
|
+
zip: JSZip,
|
|
325
|
+
remaining: { bytes: number },
|
|
326
|
+
): Promise<ParsedManifest | null> {
|
|
327
|
+
const text = await readZipText(zip, "manifest.json", remaining);
|
|
298
328
|
const parsed = ManifestSchema.safeParse(parseJson(text));
|
|
299
329
|
return parsed.success ? parsed.data : null;
|
|
300
330
|
}
|
|
@@ -357,10 +387,24 @@ function validateManifestVersion(manifest: ParsedManifest | null):
|
|
|
357
387
|
return { ok: true, warning: null };
|
|
358
388
|
}
|
|
359
389
|
|
|
390
|
+
function bombError(warnings: string[] = []): ParsedLogImport {
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
message: `Archive decompresses to more than ${MAX_ZIP_DECOMPRESSED_BYTES} bytes.`,
|
|
394
|
+
warnings,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
360
398
|
async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
|
|
361
399
|
const warnings: string[] = [];
|
|
362
400
|
const zip = await JSZip.loadAsync(data);
|
|
363
|
-
|
|
401
|
+
|
|
402
|
+
// Tracks remaining budget for decompressed bytes. Decremented on every read;
|
|
403
|
+
// any read that pushes it below zero marks the archive as a zip bomb.
|
|
404
|
+
const remaining = { bytes: MAX_ZIP_DECOMPRESSED_BYTES };
|
|
405
|
+
|
|
406
|
+
const manifest = await readManifest(zip, remaining);
|
|
407
|
+
if (remaining.bytes < 0) return bombError();
|
|
364
408
|
const versionCheck = validateManifestVersion(manifest);
|
|
365
409
|
if (!versionCheck.ok) {
|
|
366
410
|
return {
|
|
@@ -378,16 +422,22 @@ async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
|
|
|
378
422
|
warnings.push("Imported redacted export. Secrets and some replay details may be unavailable.");
|
|
379
423
|
}
|
|
380
424
|
|
|
381
|
-
const metadataText = await readZipText(zip, "logs.json");
|
|
425
|
+
const metadataText = await readZipText(zip, "logs.json", remaining);
|
|
426
|
+
if (remaining.bytes < 0) return bombError(warnings);
|
|
382
427
|
const metadataLogs = metadataText === null ? null : parseJsonLogs(metadataText);
|
|
383
428
|
const logs: ImportedLogDraft[] = [];
|
|
384
429
|
let skipped = 0;
|
|
385
430
|
|
|
386
431
|
if (metadataLogs !== null) {
|
|
387
432
|
for (const log of metadataLogs) {
|
|
388
|
-
const requestText = await readZipText(zip, `#${String(log.id)}.Request.json
|
|
389
|
-
const responseText = await readZipText(zip, `#${String(log.id)}.Response.json
|
|
390
|
-
const streamingText = await readZipText(
|
|
433
|
+
const requestText = await readZipText(zip, `#${String(log.id)}.Request.json`, remaining);
|
|
434
|
+
const responseText = await readZipText(zip, `#${String(log.id)}.Response.json`, remaining);
|
|
435
|
+
const streamingText = await readZipText(
|
|
436
|
+
zip,
|
|
437
|
+
`#${String(log.id)}.SSE.Response.json`,
|
|
438
|
+
remaining,
|
|
439
|
+
);
|
|
440
|
+
if (remaining.bytes < 0) return bombError(warnings);
|
|
391
441
|
logs.push(
|
|
392
442
|
draftFromCapturedLog(
|
|
393
443
|
log,
|
|
@@ -401,9 +451,14 @@ async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
|
|
|
401
451
|
}
|
|
402
452
|
|
|
403
453
|
for (const originalId of manifest?.logIds ?? []) {
|
|
404
|
-
const requestText = await readZipText(zip, `#${String(originalId)}.Request.json
|
|
405
|
-
const responseText = await readZipText(zip, `#${String(originalId)}.Response.json
|
|
406
|
-
const streamingText = await readZipText(
|
|
454
|
+
const requestText = await readZipText(zip, `#${String(originalId)}.Request.json`, remaining);
|
|
455
|
+
const responseText = await readZipText(zip, `#${String(originalId)}.Response.json`, remaining);
|
|
456
|
+
const streamingText = await readZipText(
|
|
457
|
+
zip,
|
|
458
|
+
`#${String(originalId)}.SSE.Response.json`,
|
|
459
|
+
remaining,
|
|
460
|
+
);
|
|
461
|
+
if (remaining.bytes < 0) return bombError(warnings);
|
|
407
462
|
if (requestText === null && responseText === null && streamingText === null) {
|
|
408
463
|
skipped += 1;
|
|
409
464
|
continue;
|
|
@@ -469,9 +524,24 @@ export async function parseImportedLogFile(input: {
|
|
|
469
524
|
}): Promise<ParsedLogImport> {
|
|
470
525
|
try {
|
|
471
526
|
if (looksLikeZip(input.data, input.fileName)) {
|
|
527
|
+
if (input.data.byteLength > MAX_ZIP_COMPRESSED_BYTES) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
message: `Archive exceeds maximum size of ${MAX_ZIP_COMPRESSED_BYTES} bytes.`,
|
|
531
|
+
warnings: [],
|
|
532
|
+
};
|
|
533
|
+
}
|
|
472
534
|
return await parseZipLogImport(input.data);
|
|
473
535
|
}
|
|
474
536
|
|
|
537
|
+
if (input.data.byteLength > MAX_ZIP_COMPRESSED_BYTES) {
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
message: `Import file exceeds maximum size of ${MAX_ZIP_COMPRESSED_BYTES} bytes.`,
|
|
541
|
+
warnings: [],
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
475
545
|
return parseTextLogImport(new TextDecoder("utf-8").decode(input.data));
|
|
476
546
|
} catch (err) {
|
|
477
547
|
return {
|
package/src/proxy/logIndex.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createInterface } from "node:readline";
|
|
|
5
5
|
import { Buffer } from "node:buffer";
|
|
6
6
|
import { resolveLogDir, logger } from "./logger";
|
|
7
7
|
import type { ApiFormat, CapturedLog } from "./schemas";
|
|
8
|
+
import { textByteLength } from "../lib/unknown";
|
|
8
9
|
import {
|
|
9
10
|
findSqliteLogIndexEntry,
|
|
10
11
|
getSqliteLogIndexMaxId,
|
|
@@ -82,10 +83,6 @@ function getIndexPath(): string {
|
|
|
82
83
|
let cachedIndex: LogIndex | null = null;
|
|
83
84
|
let sqliteBackfillScheduledMaxId = 0;
|
|
84
85
|
|
|
85
|
-
function textByteLength(value: string | null): number | null {
|
|
86
|
-
return value === null ? null : Buffer.byteLength(value, "utf8");
|
|
87
|
-
}
|
|
88
|
-
|
|
89
86
|
export function createLogIndexEntryMetadata(log: CapturedLog): LogIndexEntryMetadata {
|
|
90
87
|
return {
|
|
91
88
|
sessionId: log.sessionId,
|