@apitap/core 1.4.0 → 1.4.1
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 +2 -2
- package/dist/auth/crypto.d.ts +10 -0
- package/dist/auth/crypto.js +30 -6
- package/dist/auth/crypto.js.map +1 -1
- package/dist/auth/handoff.js +20 -1
- package/dist/auth/handoff.js.map +1 -1
- package/dist/auth/manager.d.ts +1 -0
- package/dist/auth/manager.js +35 -9
- package/dist/auth/manager.js.map +1 -1
- package/dist/capture/monitor.js +4 -0
- package/dist/capture/monitor.js.map +1 -1
- package/dist/capture/scrubber.js +10 -0
- package/dist/capture/scrubber.js.map +1 -1
- package/dist/capture/session.js +7 -17
- package/dist/capture/session.js.map +1 -1
- package/dist/cli.js +74 -17
- package/dist/cli.js.map +1 -1
- package/dist/discovery/fetch.js +3 -3
- package/dist/discovery/fetch.js.map +1 -1
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +59 -33
- package/dist/mcp.js.map +1 -1
- package/dist/native-host.js +2 -2
- package/dist/native-host.js.map +1 -1
- package/dist/orchestration/browse.js +13 -4
- package/dist/orchestration/browse.js.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +14 -4
- package/dist/plugin.js.map +1 -1
- package/dist/read/decoders/reddit.js +4 -0
- package/dist/read/decoders/reddit.js.map +1 -1
- package/dist/replay/engine.js +60 -17
- package/dist/replay/engine.js.map +1 -1
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +8 -1
- package/dist/serve.js.map +1 -1
- package/dist/skill/generator.d.ts +5 -0
- package/dist/skill/generator.js +30 -4
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/search.js +1 -1
- package/dist/skill/search.js.map +1 -1
- package/dist/skill/signing.js +19 -1
- package/dist/skill/signing.js.map +1 -1
- package/dist/skill/ssrf.js +71 -2
- package/dist/skill/ssrf.js.map +1 -1
- package/dist/skill/store.d.ts +2 -0
- package/dist/skill/store.js +23 -10
- package/dist/skill/store.js.map +1 -1
- package/dist/skill/validate.d.ts +10 -0
- package/dist/skill/validate.js +106 -0
- package/dist/skill/validate.js.map +1 -0
- package/package.json +1 -1
- package/src/auth/crypto.ts +14 -6
- package/src/auth/handoff.ts +19 -1
- package/src/auth/manager.ts +22 -5
- package/src/capture/monitor.ts +4 -0
- package/src/capture/scrubber.ts +12 -0
- package/src/capture/session.ts +5 -14
- package/src/cli.ts +71 -11
- package/src/discovery/fetch.ts +2 -2
- package/src/mcp.ts +58 -31
- package/src/orchestration/browse.ts +13 -4
- package/src/plugin.ts +17 -5
- package/src/read/decoders/reddit.ts +3 -3
- package/src/replay/engine.ts +65 -15
- package/src/serve.ts +10 -1
- package/src/skill/generator.ts +32 -4
- package/src/skill/search.ts +1 -1
- package/src/skill/signing.ts +20 -1
- package/src/skill/ssrf.ts +69 -2
- package/src/skill/store.ts +29 -11
- package/src/skill/validate.ts +48 -0
package/src/discovery/fetch.ts
CHANGED
|
@@ -27,9 +27,9 @@ export async function safeFetch(
|
|
|
27
27
|
url: string,
|
|
28
28
|
options: SafeFetchOptions = {},
|
|
29
29
|
): Promise<FetchResult | null> {
|
|
30
|
-
// SSRF check
|
|
30
|
+
// M18: Use DNS-resolving SSRF check to prevent rebinding attacks
|
|
31
31
|
if (!options.skipSsrf) {
|
|
32
|
-
const ssrfResult =
|
|
32
|
+
const ssrfResult = await resolveAndValidateUrl(url);
|
|
33
33
|
if (!ssrfResult.safe) return null;
|
|
34
34
|
}
|
|
35
35
|
|
package/src/mcp.ts
CHANGED
|
@@ -49,14 +49,40 @@ export interface McpServerOptions {
|
|
|
49
49
|
skillsDir?: string;
|
|
50
50
|
/** @internal Skip SSRF check in replay — for testing only */
|
|
51
51
|
_skipSsrfCheck?: boolean;
|
|
52
|
+
/** Rate limit: max outbound requests per minute (default: 60). Set 0 to disable. */
|
|
53
|
+
rateLimitPerMinute?: number;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
const MAX_SESSIONS = 3;
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* M23: Simple sliding-window rate limiter for outbound MCP tool calls.
|
|
60
|
+
* Prevents misconfigured agents from flooding target APIs.
|
|
61
|
+
*/
|
|
62
|
+
class RateLimiter {
|
|
63
|
+
private timestamps: number[] = [];
|
|
64
|
+
private readonly maxPerMinute: number;
|
|
65
|
+
|
|
66
|
+
constructor(maxPerMinute: number) {
|
|
67
|
+
this.maxPerMinute = maxPerMinute;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
check(): boolean {
|
|
71
|
+
if (this.maxPerMinute <= 0) return true; // Disabled
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const windowStart = now - 60_000;
|
|
74
|
+
this.timestamps = this.timestamps.filter(t => t > windowStart);
|
|
75
|
+
if (this.timestamps.length >= this.maxPerMinute) return false;
|
|
76
|
+
this.timestamps.push(now);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
57
82
|
const skillsDir = options.skillsDir;
|
|
58
83
|
const sessions = new Map<string, CaptureSession>();
|
|
59
84
|
const sessionCache = new SessionCache();
|
|
85
|
+
const rateLimiter = new RateLimiter(options.rateLimitPerMinute ?? 60);
|
|
60
86
|
|
|
61
87
|
const server = new McpServer({
|
|
62
88
|
name: 'apitap',
|
|
@@ -80,9 +106,7 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
80
106
|
},
|
|
81
107
|
async ({ query }) => {
|
|
82
108
|
const result = await searchSkills(query, skillsDir);
|
|
83
|
-
return
|
|
84
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
85
|
-
};
|
|
109
|
+
return wrapExternalContent(result, 'apitap_search');
|
|
86
110
|
},
|
|
87
111
|
);
|
|
88
112
|
|
|
@@ -103,6 +127,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
103
127
|
},
|
|
104
128
|
},
|
|
105
129
|
async ({ url }) => {
|
|
130
|
+
if (!rateLimiter.check()) {
|
|
131
|
+
return { content: [{ type: 'text' as const, text: 'Rate limit exceeded. Try again in a moment.' }], isError: true };
|
|
132
|
+
}
|
|
106
133
|
try {
|
|
107
134
|
if (!options._skipSsrfCheck) {
|
|
108
135
|
const validation = await resolveAndValidateUrl(url);
|
|
@@ -112,18 +139,18 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
112
139
|
}
|
|
113
140
|
const result = await discover(url);
|
|
114
141
|
|
|
115
|
-
// If we got a skill file, save it automatically
|
|
142
|
+
// If we got a skill file, sign and save it automatically
|
|
116
143
|
if (result.skillFile && (result.confidence === 'high' || result.confidence === 'medium')) {
|
|
117
144
|
const { writeSkillFile } = await import('./skill/store.js');
|
|
145
|
+
const { signSkillFile } = await import('./skill/signing.js');
|
|
146
|
+
const machineId = await getMachineId();
|
|
147
|
+
const sigKey = deriveSigningKey(machineId);
|
|
148
|
+
result.skillFile = signSkillFile(result.skillFile, sigKey);
|
|
118
149
|
const path = await writeSkillFile(result.skillFile, skillsDir);
|
|
119
|
-
return {
|
|
120
|
-
content: [{ type: 'text' as const, text: JSON.stringify({ ...result, savedTo: path }) }],
|
|
121
|
-
};
|
|
150
|
+
return wrapExternalContent({ ...result, savedTo: path }, 'apitap_discover');
|
|
122
151
|
}
|
|
123
152
|
|
|
124
|
-
return
|
|
125
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
126
|
-
};
|
|
153
|
+
return wrapExternalContent(result, 'apitap_discover');
|
|
127
154
|
} catch (err: any) {
|
|
128
155
|
return {
|
|
129
156
|
content: [{ type: 'text' as const, text: `Discovery failed: ${err.message}` }],
|
|
@@ -154,6 +181,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
154
181
|
},
|
|
155
182
|
},
|
|
156
183
|
async ({ domain, endpointId, params, fresh, maxBytes }) => {
|
|
184
|
+
if (!rateLimiter.check()) {
|
|
185
|
+
return { content: [{ type: 'text' as const, text: 'Rate limit exceeded. Try again in a moment.' }], isError: true };
|
|
186
|
+
}
|
|
157
187
|
const machineId = await getMachineId();
|
|
158
188
|
const signingKey = deriveSigningKey(machineId);
|
|
159
189
|
const skill = await readSkillFile(domain, skillsDir, { verifySignature: true, signingKey });
|
|
@@ -258,6 +288,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
258
288
|
},
|
|
259
289
|
},
|
|
260
290
|
async ({ url, task, maxBytes }) => {
|
|
291
|
+
if (!rateLimiter.check()) {
|
|
292
|
+
return { content: [{ type: 'text' as const, text: 'Rate limit exceeded. Try again in a moment.' }], isError: true };
|
|
293
|
+
}
|
|
261
294
|
if (!options._skipSsrfCheck) {
|
|
262
295
|
const validation = await resolveAndValidateUrl(url);
|
|
263
296
|
if (!validation.safe) {
|
|
@@ -274,13 +307,8 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
274
307
|
// In test mode, disable bridge to avoid connecting to real socket
|
|
275
308
|
...(options._skipSsrfCheck ? { _bridgeSocketPath: '/nonexistent' } : {}),
|
|
276
309
|
});
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
return wrapExternalContent(result, 'apitap_browse');
|
|
280
|
-
}
|
|
281
|
-
return {
|
|
282
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
283
|
-
};
|
|
310
|
+
// Always mark as untrusted — failed results may contain attacker-controlled strings (H7 fix)
|
|
311
|
+
return wrapExternalContent(result, 'apitap_browse');
|
|
284
312
|
},
|
|
285
313
|
);
|
|
286
314
|
|
|
@@ -336,6 +364,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
336
364
|
},
|
|
337
365
|
},
|
|
338
366
|
async ({ url, maxBytes }) => {
|
|
367
|
+
if (!rateLimiter.check()) {
|
|
368
|
+
return { content: [{ type: 'text' as const, text: 'Rate limit exceeded. Try again in a moment.' }], isError: true };
|
|
369
|
+
}
|
|
339
370
|
try {
|
|
340
371
|
if (!options._skipSsrfCheck) {
|
|
341
372
|
const validation = await resolveAndValidateUrl(url);
|
|
@@ -404,9 +435,7 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
404
435
|
setTimeout(() => reject(new Error('Capture timed out')), timeoutMs),
|
|
405
436
|
),
|
|
406
437
|
]);
|
|
407
|
-
return
|
|
408
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
409
|
-
};
|
|
438
|
+
return wrapExternalContent(result, 'apitap_capture');
|
|
410
439
|
} catch (err: any) {
|
|
411
440
|
try { await session.abort(); } catch { /* already closed */ }
|
|
412
441
|
return {
|
|
@@ -457,9 +486,8 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
457
486
|
});
|
|
458
487
|
const snapshot = await session.start(url);
|
|
459
488
|
sessions.set(session.id, session);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
};
|
|
489
|
+
// Mark as untrusted — snapshot contains external page content (H5 fix)
|
|
490
|
+
return wrapExternalContent({ sessionId: session.id, snapshot }, 'apitap_capture_start');
|
|
463
491
|
} catch (err: any) {
|
|
464
492
|
return {
|
|
465
493
|
content: [{ type: 'text' as const, text: `Failed to start capture session: ${err.message}` }],
|
|
@@ -518,8 +546,10 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
518
546
|
submit,
|
|
519
547
|
});
|
|
520
548
|
|
|
549
|
+
// Mark as untrusted — result contains external page content (H5 fix)
|
|
550
|
+
const wrapped = wrapExternalContent(result, 'apitap_capture_interact');
|
|
521
551
|
return {
|
|
522
|
-
|
|
552
|
+
...wrapped,
|
|
523
553
|
...(result.success ? {} : { isError: true }),
|
|
524
554
|
};
|
|
525
555
|
},
|
|
@@ -560,15 +590,11 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
560
590
|
try {
|
|
561
591
|
if (shouldAbort) {
|
|
562
592
|
await session.abort();
|
|
563
|
-
return {
|
|
564
|
-
content: [{ type: 'text' as const, text: JSON.stringify({ aborted: true, domains: [] }) }],
|
|
565
|
-
};
|
|
593
|
+
return wrapExternalContent({ aborted: true, domains: [] }, 'apitap_capture_finish');
|
|
566
594
|
}
|
|
567
595
|
|
|
568
596
|
const result = await session.finish();
|
|
569
|
-
return
|
|
570
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
571
|
-
};
|
|
597
|
+
return wrapExternalContent(result, 'apitap_capture_finish');
|
|
572
598
|
} catch (err: any) {
|
|
573
599
|
return {
|
|
574
600
|
content: [{ type: 'text' as const, text: `Finish failed: ${err.message}` }],
|
|
@@ -609,8 +635,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
609
635
|
timeout: timeout ? timeout * 1000 : undefined,
|
|
610
636
|
});
|
|
611
637
|
|
|
638
|
+
const wrapped = wrapExternalContent(result, 'apitap_auth_request');
|
|
612
639
|
return {
|
|
613
|
-
|
|
640
|
+
...wrapped,
|
|
614
641
|
...(result.success ? {} : { isError: true }),
|
|
615
642
|
};
|
|
616
643
|
} catch (err: any) {
|
|
@@ -4,6 +4,9 @@ import { replayEndpoint } from '../replay/engine.js';
|
|
|
4
4
|
import { SessionCache } from './cache.js';
|
|
5
5
|
import { read } from '../read/index.js';
|
|
6
6
|
import { bridgeAvailable, requestBridgeCapture, DEFAULT_SOCKET } from '../bridge/client.js';
|
|
7
|
+
import { signSkillFile } from '../skill/signing.js';
|
|
8
|
+
import { deriveSigningKey } from '../auth/crypto.js';
|
|
9
|
+
import { getMachineId } from '../auth/manager.js';
|
|
7
10
|
|
|
8
11
|
export interface BrowseOptions {
|
|
9
12
|
skillsDir?: string;
|
|
@@ -61,11 +64,14 @@ async function tryBridgeCapture(
|
|
|
61
64
|
|
|
62
65
|
if (result.success && result.skillFiles && result.skillFiles.length > 0) {
|
|
63
66
|
const skillFiles = result.skillFiles;
|
|
64
|
-
//
|
|
67
|
+
// Sign and save each skill file to disk
|
|
65
68
|
try {
|
|
66
69
|
const { writeSkillFile: writeSF } = await import('../skill/store.js');
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
const mid = await getMachineId();
|
|
71
|
+
const sk = deriveSigningKey(mid);
|
|
72
|
+
for (let i = 0; i < skillFiles.length; i++) {
|
|
73
|
+
skillFiles[i] = signSkillFile(skillFiles[i], sk);
|
|
74
|
+
await writeSF(skillFiles[i], options.skillsDir);
|
|
69
75
|
}
|
|
70
76
|
} catch {
|
|
71
77
|
// Saving failed — still have the data in memory
|
|
@@ -202,7 +208,10 @@ export async function browse(
|
|
|
202
208
|
skill = discovery.skillFile;
|
|
203
209
|
source = 'discovered';
|
|
204
210
|
|
|
205
|
-
//
|
|
211
|
+
// Sign and save to disk (H1: skill files must be signed for verification)
|
|
212
|
+
const machineId = await getMachineId();
|
|
213
|
+
const sigKey = deriveSigningKey(machineId);
|
|
214
|
+
skill = signSkillFile(skill, sigKey);
|
|
206
215
|
const { writeSkillFile: writeSF } = await import('../skill/store.js');
|
|
207
216
|
await writeSF(skill, skillsDir);
|
|
208
217
|
cache?.set(domain, skill, 'discovered');
|
package/src/plugin.ts
CHANGED
|
@@ -21,12 +21,17 @@ export interface Plugin {
|
|
|
21
21
|
|
|
22
22
|
export interface PluginOptions {
|
|
23
23
|
skillsDir?: string;
|
|
24
|
-
/** @internal
|
|
24
|
+
/** @internal Testing only — never expose to external callers (M14) */
|
|
25
25
|
_skipSsrfCheck?: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const APITAP_DIR = join(homedir(), '.apitap');
|
|
29
29
|
|
|
30
|
+
/** M20: Mark plugin responses as untrusted external content */
|
|
31
|
+
function wrapUntrusted(data: unknown): unknown {
|
|
32
|
+
return { ...data as Record<string, unknown>, _meta: { externalContent: { untrusted: true } } };
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
export function createPlugin(options: PluginOptions = {}): Plugin {
|
|
31
36
|
const skillsDir = options.skillsDir;
|
|
32
37
|
|
|
@@ -53,7 +58,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
|
|
|
53
58
|
},
|
|
54
59
|
execute: async (args) => {
|
|
55
60
|
const query = args.query as string;
|
|
56
|
-
return searchSkills(query, skillsDir);
|
|
61
|
+
return wrapUntrusted(await searchSkills(query, skillsDir));
|
|
57
62
|
},
|
|
58
63
|
};
|
|
59
64
|
|
|
@@ -88,7 +93,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
|
|
|
88
93
|
const endpointId = args.endpointId as string;
|
|
89
94
|
const params = args.params as Record<string, string> | undefined;
|
|
90
95
|
|
|
91
|
-
const skill = await readSkillFile(domain, skillsDir);
|
|
96
|
+
const skill = await readSkillFile(domain, skillsDir, { trustUnsigned: true });
|
|
92
97
|
if (!skill) {
|
|
93
98
|
return {
|
|
94
99
|
error: `No skill file found for "${domain}". Use apitap_capture to capture it first.`,
|
|
@@ -104,7 +109,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
|
|
|
104
109
|
domain,
|
|
105
110
|
_skipSsrfCheck: options._skipSsrfCheck,
|
|
106
111
|
});
|
|
107
|
-
return { status: result.status, data: result.data };
|
|
112
|
+
return wrapUntrusted({ status: result.status, data: result.data });
|
|
108
113
|
} catch (err: any) {
|
|
109
114
|
return { error: err.message };
|
|
110
115
|
}
|
|
@@ -142,6 +147,13 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
|
|
|
142
147
|
const duration = (args.duration as number) ?? 30;
|
|
143
148
|
const allDomains = (args.allDomains as boolean) ?? false;
|
|
144
149
|
|
|
150
|
+
// M19: SSRF validation before capture
|
|
151
|
+
const { resolveAndValidateUrl } = await import('./skill/ssrf.js');
|
|
152
|
+
const ssrfCheck = await resolveAndValidateUrl(url);
|
|
153
|
+
if (!ssrfCheck.safe) {
|
|
154
|
+
return { error: `Blocked: ${ssrfCheck.reason}` };
|
|
155
|
+
}
|
|
156
|
+
|
|
145
157
|
// Shell out to CLI for capture (it handles browser lifecycle, signing, etc.)
|
|
146
158
|
const { execFile } = await import('node:child_process');
|
|
147
159
|
const { promisify } = await import('node:util');
|
|
@@ -155,7 +167,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
|
|
|
155
167
|
timeout: (duration + 30) * 1000,
|
|
156
168
|
env: { ...process.env, ...(skillsDir ? { APITAP_SKILLS_DIR: skillsDir } : {}) },
|
|
157
169
|
});
|
|
158
|
-
return JSON.parse(stdout);
|
|
170
|
+
return wrapUntrusted(JSON.parse(stdout));
|
|
159
171
|
} catch (err: any) {
|
|
160
172
|
return { error: `Capture failed: ${err.message}` };
|
|
161
173
|
}
|
|
@@ -58,9 +58,9 @@ async function recoverDeletedComments(
|
|
|
58
58
|
const recovered = new Map<string, { author: string; body: string }>();
|
|
59
59
|
if (commentIds.length === 0) return recovered;
|
|
60
60
|
|
|
61
|
-
// Third-party
|
|
62
|
-
//
|
|
63
|
-
if (process.env.
|
|
61
|
+
// M21: Third-party requests are opt-in, not opt-out.
|
|
62
|
+
// PullPush is an external service — only contact it if explicitly enabled.
|
|
63
|
+
if (process.env.APITAP_THIRD_PARTY !== '1') return recovered;
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
66
|
const ids = commentIds.join(',');
|
package/src/replay/engine.ts
CHANGED
|
@@ -139,6 +139,41 @@ function normalizeOptions(
|
|
|
139
139
|
return { params: optionsOrParams as Record<string, string> };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Check if redirect host is safe to send auth to.
|
|
144
|
+
* Allows exact match or single-level subdomain only (H4 fix).
|
|
145
|
+
*/
|
|
146
|
+
function isSafeAuthRedirectHost(originalHost: string, redirectHost: string): boolean {
|
|
147
|
+
if (redirectHost === originalHost) return true;
|
|
148
|
+
if (redirectHost.endsWith('.' + originalHost)) {
|
|
149
|
+
const prefix = redirectHost.slice(0, -(originalHost.length + 1));
|
|
150
|
+
// Only allow single-level subdomain (no dots in prefix)
|
|
151
|
+
return !prefix.includes('.');
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Strip auth headers from redirect request if cross-domain (H4 fix).
|
|
158
|
+
* Shared between initial and retry redirect paths.
|
|
159
|
+
*/
|
|
160
|
+
function stripAuthForRedirect(
|
|
161
|
+
headers: Record<string, string>,
|
|
162
|
+
originalHost: string,
|
|
163
|
+
redirectHost: string,
|
|
164
|
+
): Record<string, string> {
|
|
165
|
+
const redirectHeaders = { ...headers };
|
|
166
|
+
if (!isSafeAuthRedirectHost(originalHost, redirectHost)) {
|
|
167
|
+
delete redirectHeaders['authorization'];
|
|
168
|
+
for (const key of Object.keys(redirectHeaders)) {
|
|
169
|
+
if (key.toLowerCase() === 'authorization' || redirectHeaders[key] === '[stored]') {
|
|
170
|
+
delete redirectHeaders[key];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return redirectHeaders;
|
|
175
|
+
}
|
|
176
|
+
|
|
142
177
|
/**
|
|
143
178
|
* Wrap a 401/403 response with structured auth guidance.
|
|
144
179
|
*/
|
|
@@ -209,7 +244,7 @@ export async function replayEndpoint(
|
|
|
209
244
|
// SSRF validation — resolve DNS and check the IP isn't private/internal.
|
|
210
245
|
// We do NOT substitute the IP into the URL because that breaks TLS/SNI
|
|
211
246
|
// for sites behind CDNs (Cloudflare, etc.) where the cert is for the hostname.
|
|
212
|
-
//
|
|
247
|
+
// M15: We re-validate DNS after fetch to narrow the TOCTOU window.
|
|
213
248
|
const fetchUrl = url.toString();
|
|
214
249
|
if (!options._skipSsrfCheck) {
|
|
215
250
|
const ssrfCheck = await resolveAndValidateUrl(url.toString());
|
|
@@ -237,6 +272,19 @@ export async function replayEndpoint(
|
|
|
237
272
|
}
|
|
238
273
|
}
|
|
239
274
|
|
|
275
|
+
// Domain-lock: verify request URL matches skill domain before injecting auth (C1 fix).
|
|
276
|
+
// validateSkillFile() enforces baseUrl-domain consistency at load time.
|
|
277
|
+
// This is defense-in-depth for manually-constructed SkillFile objects.
|
|
278
|
+
if (authManager && domain && !options._skipSsrfCheck) {
|
|
279
|
+
const fetchHost = url.hostname;
|
|
280
|
+
if (fetchHost !== skill.domain && !fetchHost.endsWith('.' + skill.domain)) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Domain-lock violation: request host "${fetchHost}" does not match skill domain "${skill.domain}". ` +
|
|
283
|
+
`Auth injection blocked to prevent credential exfiltration.`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
240
288
|
// Inject auth header from auth manager (if available)
|
|
241
289
|
if (authManager && domain) {
|
|
242
290
|
const auth = endpoint.isolatedAuth
|
|
@@ -376,6 +424,16 @@ export async function replayEndpoint(
|
|
|
376
424
|
redirect: 'manual', // Don't auto-follow redirects
|
|
377
425
|
});
|
|
378
426
|
|
|
427
|
+
// M15: Post-fetch DNS re-validation to narrow TOCTOU window.
|
|
428
|
+
// Re-resolves DNS and checks if the hostname now points to a private IP
|
|
429
|
+
// (indicates DNS rebinding attack between our pre-check and the actual connection).
|
|
430
|
+
if (!options._skipSsrfCheck && url.hostname && !url.hostname.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
|
|
431
|
+
const postCheck = await resolveAndValidateUrl(fetchUrl);
|
|
432
|
+
if (!postCheck.safe) {
|
|
433
|
+
throw new Error(`DNS rebinding detected (post-fetch): ${postCheck.reason}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
379
437
|
// Handle redirects with SSRF validation (single hop only)
|
|
380
438
|
if (response.status >= 300 && response.status < 400) {
|
|
381
439
|
const location = response.headers.get('location');
|
|
@@ -388,18 +446,8 @@ export async function replayEndpoint(
|
|
|
388
446
|
throw new Error(`Redirect blocked (SSRF): ${redirectCheck.reason}`);
|
|
389
447
|
}
|
|
390
448
|
}
|
|
391
|
-
// Strip auth headers before cross-domain redirect
|
|
392
|
-
const redirectHeaders =
|
|
393
|
-
const originalHost = url.hostname;
|
|
394
|
-
const redirectHost = redirectUrl.hostname;
|
|
395
|
-
if (redirectHost !== originalHost && !redirectHost.endsWith('.' + originalHost)) {
|
|
396
|
-
delete redirectHeaders['authorization'];
|
|
397
|
-
for (const key of Object.keys(redirectHeaders)) {
|
|
398
|
-
if (key.toLowerCase() === 'authorization' || redirectHeaders[key] === '[stored]') {
|
|
399
|
-
delete redirectHeaders[key];
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
449
|
+
// Strip auth headers before cross-domain redirect (uses shared function)
|
|
450
|
+
const redirectHeaders = stripAuthForRedirect(headers, url.hostname, redirectUrl.hostname);
|
|
403
451
|
// Follow the redirect manually (single hop to prevent chains)
|
|
404
452
|
response = await fetch(redirectFetchUrl, {
|
|
405
453
|
method: 'GET', // Redirects typically become GET
|
|
@@ -437,7 +485,7 @@ export async function replayEndpoint(
|
|
|
437
485
|
redirect: 'manual',
|
|
438
486
|
});
|
|
439
487
|
|
|
440
|
-
// Handle redirects on retry (single hop)
|
|
488
|
+
// Handle redirects on retry (single hop) — with auth stripping (H4 fix)
|
|
441
489
|
if (retryResponse.status >= 300 && retryResponse.status < 400) {
|
|
442
490
|
const location = retryResponse.headers.get('location');
|
|
443
491
|
if (location) {
|
|
@@ -449,9 +497,11 @@ export async function replayEndpoint(
|
|
|
449
497
|
throw new Error(`Redirect blocked (SSRF): ${redirectCheck.reason}`);
|
|
450
498
|
}
|
|
451
499
|
}
|
|
500
|
+
// Strip auth headers on cross-domain redirect (same logic as initial path)
|
|
501
|
+
const retryRedirectHeaders = stripAuthForRedirect(headers, url.hostname, redirectUrl.hostname);
|
|
452
502
|
retryResponse = await fetch(retryRedirectFetchUrl, {
|
|
453
503
|
method: 'GET',
|
|
454
|
-
headers,
|
|
504
|
+
headers: retryRedirectHeaders,
|
|
455
505
|
signal: AbortSignal.timeout(30_000),
|
|
456
506
|
redirect: 'manual',
|
|
457
507
|
});
|
package/src/serve.ts
CHANGED
|
@@ -75,6 +75,8 @@ export function buildServeTools(skill: SkillFile): ServeTool[] {
|
|
|
75
75
|
export interface ServeOptions {
|
|
76
76
|
skillsDir?: string;
|
|
77
77
|
noAuth?: boolean;
|
|
78
|
+
/** Allow loading unsigned skill files */
|
|
79
|
+
trustUnsigned?: boolean;
|
|
78
80
|
/** @internal Skip SSRF validation — for testing only */
|
|
79
81
|
_skipSsrfCheck?: boolean;
|
|
80
82
|
}
|
|
@@ -87,7 +89,7 @@ export async function createServeServer(
|
|
|
87
89
|
domain: string,
|
|
88
90
|
options: ServeOptions = {},
|
|
89
91
|
): Promise<McpServer> {
|
|
90
|
-
const skill = await readSkillFile(domain, options.skillsDir);
|
|
92
|
+
const skill = await readSkillFile(domain, options.skillsDir, { trustUnsigned: options.trustUnsigned });
|
|
91
93
|
if (!skill) {
|
|
92
94
|
throw new Error(`No skill file found for "${domain}". Run: apitap capture ${domain}`);
|
|
93
95
|
}
|
|
@@ -146,11 +148,18 @@ export async function createServeServer(
|
|
|
146
148
|
_skipSsrfCheck: options._skipSsrfCheck,
|
|
147
149
|
});
|
|
148
150
|
|
|
151
|
+
// M22: Mark responses as untrusted external content
|
|
149
152
|
return {
|
|
150
153
|
content: [{
|
|
151
154
|
type: 'text' as const,
|
|
152
155
|
text: JSON.stringify({ status: result.status, data: result.data }),
|
|
153
156
|
}],
|
|
157
|
+
_meta: {
|
|
158
|
+
externalContent: {
|
|
159
|
+
untrusted: true,
|
|
160
|
+
source: `apitap-serve-${domain}`,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
154
163
|
};
|
|
155
164
|
} catch (err: any) {
|
|
156
165
|
console.error('Replay failed:', err instanceof Error ? err.message : String(err));
|
package/src/skill/generator.ts
CHANGED
|
@@ -316,10 +316,10 @@ export class SkillGenerator {
|
|
|
316
316
|
this.totalNetworkBytes += exchange.response.body.length;
|
|
317
317
|
|
|
318
318
|
if (this.endpoints.has(key)) {
|
|
319
|
-
// Store duplicate body for cross-request diffing (Strategy 1)
|
|
319
|
+
// Store duplicate body for cross-request diffing (Strategy 1) — scrubbed (H3 fix)
|
|
320
320
|
if (exchange.request.postData) {
|
|
321
321
|
const bodies = this.exchangeBodies.get(key);
|
|
322
|
-
if (bodies) bodies.push(exchange.request.postData);
|
|
322
|
+
if (bodies) bodies.push(this.scrubBodyString(exchange.request.postData));
|
|
323
323
|
}
|
|
324
324
|
return null;
|
|
325
325
|
}
|
|
@@ -455,9 +455,17 @@ export class SkillGenerator {
|
|
|
455
455
|
|
|
456
456
|
this.endpoints.set(key, endpoint);
|
|
457
457
|
|
|
458
|
-
// Store first body for cross-request diffing
|
|
458
|
+
// Store first body for cross-request diffing (scrub sensitive fields — H3 fix)
|
|
459
459
|
if (exchange.request.postData) {
|
|
460
|
-
this.exchangeBodies.set(key, [exchange.request.postData]);
|
|
460
|
+
this.exchangeBodies.set(key, [this.scrubBodyString(exchange.request.postData)]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Clear auth values from exchange to reduce credential exposure window (H3 fix)
|
|
464
|
+
for (const key of Object.keys(exchange.request.headers)) {
|
|
465
|
+
const lower = key.toLowerCase();
|
|
466
|
+
if (AUTH_HEADERS.has(lower) || lower === 'cookie') {
|
|
467
|
+
exchange.request.headers[key] = '[scrubbed]';
|
|
468
|
+
}
|
|
461
469
|
}
|
|
462
470
|
|
|
463
471
|
return endpoint;
|
|
@@ -513,6 +521,23 @@ export class SkillGenerator {
|
|
|
513
521
|
this.totalNetworkBytes += bytes;
|
|
514
522
|
}
|
|
515
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Scrub sensitive fields from a POST body string for intermediate storage (H3 fix).
|
|
526
|
+
* Preserves structure for cross-request diffing while removing credentials.
|
|
527
|
+
*/
|
|
528
|
+
private scrubBodyString(bodyStr: string): string {
|
|
529
|
+
try {
|
|
530
|
+
const parsed = JSON.parse(bodyStr);
|
|
531
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
532
|
+
const scrubbed = scrubBody(parsed, true) as Record<string, unknown>;
|
|
533
|
+
return JSON.stringify(scrubbed);
|
|
534
|
+
}
|
|
535
|
+
} catch {
|
|
536
|
+
// Non-JSON body — apply PII scrubber
|
|
537
|
+
}
|
|
538
|
+
return scrubPII(bodyStr);
|
|
539
|
+
}
|
|
540
|
+
|
|
516
541
|
/** Generate the complete skill file for a domain. */
|
|
517
542
|
toSkillFile(domain: string, options?: { domBytes?: number; totalRequests?: number }): SkillFile {
|
|
518
543
|
// Apply cross-request diffing (Strategy 1) to endpoints with multiple bodies
|
|
@@ -559,6 +584,9 @@ export class SkillGenerator {
|
|
|
559
584
|
};
|
|
560
585
|
}
|
|
561
586
|
|
|
587
|
+
// Clear intermediate body storage to reduce credential exposure window (H3 fix)
|
|
588
|
+
this.exchangeBodies.clear();
|
|
589
|
+
|
|
562
590
|
return skill;
|
|
563
591
|
}
|
|
564
592
|
}
|
package/src/skill/search.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function searchSkills(
|
|
|
37
37
|
const results: SearchResult[] = [];
|
|
38
38
|
|
|
39
39
|
for (const summary of summaries) {
|
|
40
|
-
const skill = await readSkillFile(summary.domain, skillsDir);
|
|
40
|
+
const skill = await readSkillFile(summary.domain, skillsDir, { trustUnsigned: true });
|
|
41
41
|
if (!skill) continue;
|
|
42
42
|
|
|
43
43
|
const domainLower = skill.domain.toLowerCase();
|
package/src/skill/signing.ts
CHANGED
|
@@ -9,7 +9,26 @@ import type { SkillFile } from '../types.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export function canonicalize(skill: SkillFile): string {
|
|
11
11
|
const { signature: _sig, provenance: _prov, ...rest } = skill;
|
|
12
|
-
return JSON.stringify(
|
|
12
|
+
return JSON.stringify(sortKeysDeep(rest));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Recursively sort all object keys for stable canonicalization (M10 fix).
|
|
17
|
+
* Ensures identical skill files always produce the same canonical string
|
|
18
|
+
* regardless of key insertion order at any nesting level.
|
|
19
|
+
*/
|
|
20
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
return value.map(sortKeysDeep);
|
|
23
|
+
}
|
|
24
|
+
if (value !== null && typeof value === 'object') {
|
|
25
|
+
const sorted: Record<string, unknown> = {};
|
|
26
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
27
|
+
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
|
28
|
+
}
|
|
29
|
+
return sorted;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
13
32
|
}
|
|
14
33
|
|
|
15
34
|
/**
|