@apitap/core 1.3.1 → 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 +32 -6
- package/src/auth/handoff.ts +19 -1
- package/src/auth/manager.ts +33 -9
- package/src/capture/monitor.ts +4 -0
- package/src/capture/scrubber.ts +12 -0
- package/src/capture/session.ts +7 -16
- package/src/cli.ts +78 -17
- package/src/discovery/fetch.ts +2 -2
- package/src/mcp.ts +62 -33
- package/src/native-host.ts +2 -2
- package/src/orchestration/browse.ts +13 -4
- package/src/plugin.ts +17 -5
- package/src/read/decoders/reddit.ts +4 -0
- package/src/replay/engine.ts +70 -17
- 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 +32 -11
- package/src/skill/validate.ts +120 -0
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { capture } from './capture/monitor.js';
|
|
|
4
4
|
import { writeSkillFile, readSkillFile, listSkillFiles } from './skill/store.js';
|
|
5
5
|
import { replayEndpoint } from './replay/engine.js';
|
|
6
6
|
import { AuthManager, getMachineId } from './auth/manager.js';
|
|
7
|
-
import {
|
|
7
|
+
import { deriveSigningKey } from './auth/crypto.js';
|
|
8
8
|
import { signSkillFile } from './skill/signing.js';
|
|
9
9
|
import { importSkillFile } from './skill/importer.js';
|
|
10
10
|
import { resolveAndValidateUrl } from './skill/ssrf.js';
|
|
@@ -158,6 +158,15 @@ async function handleCapture(positional: string[], flags: Record<string, string
|
|
|
158
158
|
const skipVerify = flags['no-verify'] === true;
|
|
159
159
|
const verifyPosts = flags['verify-posts'] === true;
|
|
160
160
|
|
|
161
|
+
// SSRF validation for CLI (H6 fix)
|
|
162
|
+
if (flags['danger-disable-ssrf'] !== true) {
|
|
163
|
+
const ssrfCheck = await resolveAndValidateUrl(fullUrl);
|
|
164
|
+
if (!ssrfCheck.safe) {
|
|
165
|
+
console.error(`Error: URL blocked (SSRF): ${ssrfCheck.reason}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
if (!json) {
|
|
162
171
|
const domainOnly = flags['all-domains'] !== true;
|
|
163
172
|
console.log(`\n 🔍 Capturing ${url}...${duration ? ` (${duration}s)` : ' (Ctrl+C to stop)'}${domainOnly ? ' [domain-only]' : ' [all domains]'}\n`);
|
|
@@ -194,7 +203,7 @@ async function handleCapture(positional: string[], flags: Record<string, string
|
|
|
194
203
|
|
|
195
204
|
// Get machine ID for signing and auth storage
|
|
196
205
|
const machineId = await getMachineId();
|
|
197
|
-
const key =
|
|
206
|
+
const key = deriveSigningKey(machineId);
|
|
198
207
|
const authManager = new AuthManager(APITAP_DIR, machineId);
|
|
199
208
|
|
|
200
209
|
// Write skill files for each domain
|
|
@@ -341,7 +350,7 @@ async function handleShow(positional: string[], flags: Record<string, string | b
|
|
|
341
350
|
process.exit(1);
|
|
342
351
|
}
|
|
343
352
|
|
|
344
|
-
const skill = await readSkillFile(domain, SKILLS_DIR);
|
|
353
|
+
const skill = await readSkillFile(domain, SKILLS_DIR, { trustUnsigned: true });
|
|
345
354
|
if (!skill) {
|
|
346
355
|
console.error(`Error: No skill file found for "${domain}". Run \`apitap capture\` first.`);
|
|
347
356
|
process.exit(1);
|
|
@@ -378,7 +387,10 @@ async function handleReplay(positional: string[], flags: Record<string, string |
|
|
|
378
387
|
process.exit(1);
|
|
379
388
|
}
|
|
380
389
|
|
|
381
|
-
const
|
|
390
|
+
const machineId = await getMachineId();
|
|
391
|
+
const signingKey = deriveSigningKey(machineId);
|
|
392
|
+
const trustUnsigned = flags['trust-unsigned'] === true;
|
|
393
|
+
const skill = await readSkillFile(domain, SKILLS_DIR, { verifySignature: true, signingKey, trustUnsigned });
|
|
382
394
|
if (!skill) {
|
|
383
395
|
console.error(`Error: No skill file found for "${domain}".`);
|
|
384
396
|
process.exit(1);
|
|
@@ -394,7 +406,6 @@ async function handleReplay(positional: string[], flags: Record<string, string |
|
|
|
394
406
|
}
|
|
395
407
|
|
|
396
408
|
// Merge stored auth into endpoint headers for replay
|
|
397
|
-
const machineId = await getMachineId();
|
|
398
409
|
const authManager = new AuthManager(APITAP_DIR, machineId);
|
|
399
410
|
const storedAuth = await authManager.retrieve(domain);
|
|
400
411
|
|
|
@@ -416,6 +427,10 @@ async function handleReplay(positional: string[], flags: Record<string, string |
|
|
|
416
427
|
const fresh = flags.fresh === true;
|
|
417
428
|
const json = flags.json === true;
|
|
418
429
|
const maxBytes = typeof flags['max-bytes'] === 'string' ? parseInt(flags['max-bytes'], 10) : undefined;
|
|
430
|
+
const dangerDisableSsrf = flags['danger-disable-ssrf'] === true;
|
|
431
|
+
if (dangerDisableSsrf) {
|
|
432
|
+
console.error('[apitap] WARNING: SSRF protection is disabled via --danger-disable-ssrf');
|
|
433
|
+
}
|
|
419
434
|
|
|
420
435
|
const result = await replayEndpoint(skill, endpointId, {
|
|
421
436
|
params: Object.keys(params).length > 0 ? params : undefined,
|
|
@@ -423,7 +438,7 @@ async function handleReplay(positional: string[], flags: Record<string, string |
|
|
|
423
438
|
domain,
|
|
424
439
|
fresh,
|
|
425
440
|
maxBytes,
|
|
426
|
-
_skipSsrfCheck:
|
|
441
|
+
_skipSsrfCheck: dangerDisableSsrf,
|
|
427
442
|
});
|
|
428
443
|
|
|
429
444
|
if (json) {
|
|
@@ -446,7 +461,7 @@ async function handleImport(positional: string[], flags: Record<string, string |
|
|
|
446
461
|
|
|
447
462
|
// Get local key for signature verification
|
|
448
463
|
const machineId = await getMachineId();
|
|
449
|
-
const key =
|
|
464
|
+
const key = deriveSigningKey(machineId);
|
|
450
465
|
|
|
451
466
|
// DNS-resolving SSRF check before importing (prevents DNS rebinding attacks)
|
|
452
467
|
try {
|
|
@@ -492,7 +507,8 @@ async function handleRefresh(positional: string[], flags: Record<string, string
|
|
|
492
507
|
process.exit(1);
|
|
493
508
|
}
|
|
494
509
|
|
|
495
|
-
const
|
|
510
|
+
const trustUnsigned = flags['trust-unsigned'] === true;
|
|
511
|
+
const skill = await readSkillFile(domain, SKILLS_DIR, { trustUnsigned });
|
|
496
512
|
if (!skill) {
|
|
497
513
|
console.error(`Error: No skill file found for "${domain}".`);
|
|
498
514
|
process.exit(1);
|
|
@@ -596,8 +612,8 @@ async function handleAuth(positional: string[], flags: Record<string, string | b
|
|
|
596
612
|
}
|
|
597
613
|
}
|
|
598
614
|
|
|
599
|
-
// Read skill file for OAuth config (non-secret)
|
|
600
|
-
const skill = await readSkillFile(domain, SKILLS_DIR);
|
|
615
|
+
// Read skill file for OAuth config (non-secret) — trustUnsigned for display only
|
|
616
|
+
const skill = await readSkillFile(domain, SKILLS_DIR, { trustUnsigned: true });
|
|
601
617
|
const oauthConfig = skill?.auth?.oauthConfig;
|
|
602
618
|
|
|
603
619
|
const status = {
|
|
@@ -659,7 +675,7 @@ async function handleServe(positional: string[], flags: Record<string, string |
|
|
|
659
675
|
});
|
|
660
676
|
|
|
661
677
|
// Print tool list to stderr (stdout is the MCP transport)
|
|
662
|
-
const skill = await readSkillFile(domain, SKILLS_DIR);
|
|
678
|
+
const skill = await readSkillFile(domain, SKILLS_DIR, { trustUnsigned: true });
|
|
663
679
|
const tools = buildServeTools(skill!);
|
|
664
680
|
|
|
665
681
|
if (json) {
|
|
@@ -681,10 +697,13 @@ async function handleServe(positional: string[], flags: Record<string, string |
|
|
|
681
697
|
}
|
|
682
698
|
}
|
|
683
699
|
|
|
684
|
-
async function handleMcp(): Promise<void> {
|
|
700
|
+
async function handleMcp(flags: Record<string, string | boolean>): Promise<void> {
|
|
701
|
+
if (flags['danger-disable-ssrf'] === true) {
|
|
702
|
+
console.error('[apitap] WARNING: SSRF protection is disabled via --danger-disable-ssrf');
|
|
703
|
+
}
|
|
685
704
|
const server = createMcpServer({
|
|
686
705
|
skillsDir: SKILLS_DIR,
|
|
687
|
-
_skipSsrfCheck:
|
|
706
|
+
_skipSsrfCheck: flags['danger-disable-ssrf'] === true,
|
|
688
707
|
});
|
|
689
708
|
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
690
709
|
const transport = new StdioServerTransport();
|
|
@@ -709,6 +728,16 @@ async function handleInspect(positional: string[], flags: Record<string, string
|
|
|
709
728
|
process.exit(1);
|
|
710
729
|
}
|
|
711
730
|
|
|
731
|
+
// SSRF validation for CLI (H6 fix)
|
|
732
|
+
const fullInspectUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
733
|
+
if (flags['danger-disable-ssrf'] !== true) {
|
|
734
|
+
const ssrfCheck = await resolveAndValidateUrl(fullInspectUrl);
|
|
735
|
+
if (!ssrfCheck.safe) {
|
|
736
|
+
console.error(`Error: URL blocked (SSRF): ${ssrfCheck.reason}`);
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
712
741
|
const json = flags.json === true;
|
|
713
742
|
const duration = typeof flags.duration === 'string' ? parseInt(flags.duration, 10) : 30;
|
|
714
743
|
|
|
@@ -798,6 +827,20 @@ async function handleDiscover(positional: string[], flags: Record<string, string
|
|
|
798
827
|
const json = flags.json === true;
|
|
799
828
|
const save = flags.save === true;
|
|
800
829
|
|
|
830
|
+
// SSRF validation for CLI (H6 fix)
|
|
831
|
+
const fullDiscoverUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
832
|
+
if (flags['danger-disable-ssrf'] !== true) {
|
|
833
|
+
const ssrfCheck = await resolveAndValidateUrl(fullDiscoverUrl);
|
|
834
|
+
if (!ssrfCheck.safe) {
|
|
835
|
+
if (json) {
|
|
836
|
+
console.log(JSON.stringify({ error: `URL blocked (SSRF): ${ssrfCheck.reason}` }));
|
|
837
|
+
} else {
|
|
838
|
+
console.error(`Error: URL blocked (SSRF): ${ssrfCheck.reason}`);
|
|
839
|
+
}
|
|
840
|
+
process.exit(1);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
801
844
|
if (!json) {
|
|
802
845
|
console.log(`\n Discovering APIs for ${url}...\n`);
|
|
803
846
|
}
|
|
@@ -863,9 +906,9 @@ async function handleDiscover(positional: string[], flags: Record<string, string
|
|
|
863
906
|
if (save && result.skillFile) {
|
|
864
907
|
const { writeSkillFile } = await import('./skill/store.js');
|
|
865
908
|
const { signSkillFile } = await import('./skill/signing.js');
|
|
866
|
-
const {
|
|
909
|
+
const { deriveSigningKey } = await import('./auth/crypto.js');
|
|
867
910
|
const machineId = await getMachineId();
|
|
868
|
-
const key =
|
|
911
|
+
const key = deriveSigningKey(machineId);
|
|
869
912
|
|
|
870
913
|
const signed = signSkillFile(result.skillFile, key);
|
|
871
914
|
const path = await writeSkillFile(signed, SKILLS_DIR);
|
|
@@ -901,7 +944,7 @@ async function handleBrowse(positional: string[], flags: Record<string, string |
|
|
|
901
944
|
skillsDir: SKILLS_DIR,
|
|
902
945
|
cache: new SessionCache(),
|
|
903
946
|
maxBytes,
|
|
904
|
-
_skipSsrfCheck:
|
|
947
|
+
_skipSsrfCheck: flags['danger-disable-ssrf'] === true,
|
|
905
948
|
});
|
|
906
949
|
|
|
907
950
|
if (json) {
|
|
@@ -932,6 +975,15 @@ async function handlePeek(positional: string[], flags: Record<string, string | b
|
|
|
932
975
|
const json = flags.json === true;
|
|
933
976
|
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
934
977
|
|
|
978
|
+
// SSRF validation for CLI (H6 fix)
|
|
979
|
+
if (flags['danger-disable-ssrf'] !== true) {
|
|
980
|
+
const ssrfCheck = await resolveAndValidateUrl(fullUrl);
|
|
981
|
+
if (!ssrfCheck.safe) {
|
|
982
|
+
console.error(`Error: URL blocked (SSRF): ${ssrfCheck.reason}`);
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
935
987
|
if (!json) {
|
|
936
988
|
console.log(`\n Peeking at ${url}...\n`);
|
|
937
989
|
}
|
|
@@ -963,6 +1015,15 @@ async function handleRead(positional: string[], flags: Record<string, string | b
|
|
|
963
1015
|
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
964
1016
|
const maxBytes = typeof flags['max-bytes'] === 'string' ? parseInt(flags['max-bytes'], 10) : undefined;
|
|
965
1017
|
|
|
1018
|
+
// SSRF validation for CLI (H6 fix)
|
|
1019
|
+
if (flags['danger-disable-ssrf'] !== true) {
|
|
1020
|
+
const ssrfCheck = await resolveAndValidateUrl(fullUrl);
|
|
1021
|
+
if (!ssrfCheck.safe) {
|
|
1022
|
+
console.error(`Error: URL blocked (SSRF): ${ssrfCheck.reason}`);
|
|
1023
|
+
process.exit(1);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
966
1027
|
if (!json) {
|
|
967
1028
|
console.log(`\n Reading ${url}...\n`);
|
|
968
1029
|
}
|
|
@@ -1072,7 +1133,7 @@ async function main(): Promise<void> {
|
|
|
1072
1133
|
await handleServe(positional, flags);
|
|
1073
1134
|
break;
|
|
1074
1135
|
case 'mcp':
|
|
1075
|
-
await handleMcp();
|
|
1136
|
+
await handleMcp(flags);
|
|
1076
1137
|
break;
|
|
1077
1138
|
case 'inspect':
|
|
1078
1139
|
await handleInspect(positional, flags);
|
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
|
@@ -7,6 +7,7 @@ import { searchSkills } from './skill/search.js';
|
|
|
7
7
|
import { readSkillFile } from './skill/store.js';
|
|
8
8
|
import { replayEndpoint } from './replay/engine.js';
|
|
9
9
|
import { AuthManager, getMachineId } from './auth/manager.js';
|
|
10
|
+
import { deriveSigningKey } from './auth/crypto.js';
|
|
10
11
|
import { requestAuth } from './auth/handoff.js';
|
|
11
12
|
import { CaptureSession } from './capture/session.js';
|
|
12
13
|
import { discover } from './discovery/index.js';
|
|
@@ -48,14 +49,40 @@ export interface McpServerOptions {
|
|
|
48
49
|
skillsDir?: string;
|
|
49
50
|
/** @internal Skip SSRF check in replay — for testing only */
|
|
50
51
|
_skipSsrfCheck?: boolean;
|
|
52
|
+
/** Rate limit: max outbound requests per minute (default: 60). Set 0 to disable. */
|
|
53
|
+
rateLimitPerMinute?: number;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
const MAX_SESSIONS = 3;
|
|
54
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
|
+
|
|
55
81
|
export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
56
82
|
const skillsDir = options.skillsDir;
|
|
57
83
|
const sessions = new Map<string, CaptureSession>();
|
|
58
84
|
const sessionCache = new SessionCache();
|
|
85
|
+
const rateLimiter = new RateLimiter(options.rateLimitPerMinute ?? 60);
|
|
59
86
|
|
|
60
87
|
const server = new McpServer({
|
|
61
88
|
name: 'apitap',
|
|
@@ -79,9 +106,7 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
79
106
|
},
|
|
80
107
|
async ({ query }) => {
|
|
81
108
|
const result = await searchSkills(query, skillsDir);
|
|
82
|
-
return
|
|
83
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
84
|
-
};
|
|
109
|
+
return wrapExternalContent(result, 'apitap_search');
|
|
85
110
|
},
|
|
86
111
|
);
|
|
87
112
|
|
|
@@ -102,6 +127,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
102
127
|
},
|
|
103
128
|
},
|
|
104
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
|
+
}
|
|
105
133
|
try {
|
|
106
134
|
if (!options._skipSsrfCheck) {
|
|
107
135
|
const validation = await resolveAndValidateUrl(url);
|
|
@@ -111,18 +139,18 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
111
139
|
}
|
|
112
140
|
const result = await discover(url);
|
|
113
141
|
|
|
114
|
-
// If we got a skill file, save it automatically
|
|
142
|
+
// If we got a skill file, sign and save it automatically
|
|
115
143
|
if (result.skillFile && (result.confidence === 'high' || result.confidence === 'medium')) {
|
|
116
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);
|
|
117
149
|
const path = await writeSkillFile(result.skillFile, skillsDir);
|
|
118
|
-
return {
|
|
119
|
-
content: [{ type: 'text' as const, text: JSON.stringify({ ...result, savedTo: path }) }],
|
|
120
|
-
};
|
|
150
|
+
return wrapExternalContent({ ...result, savedTo: path }, 'apitap_discover');
|
|
121
151
|
}
|
|
122
152
|
|
|
123
|
-
return
|
|
124
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
125
|
-
};
|
|
153
|
+
return wrapExternalContent(result, 'apitap_discover');
|
|
126
154
|
} catch (err: any) {
|
|
127
155
|
return {
|
|
128
156
|
content: [{ type: 'text' as const, text: `Discovery failed: ${err.message}` }],
|
|
@@ -153,7 +181,12 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
153
181
|
},
|
|
154
182
|
},
|
|
155
183
|
async ({ domain, endpointId, params, fresh, maxBytes }) => {
|
|
156
|
-
|
|
184
|
+
if (!rateLimiter.check()) {
|
|
185
|
+
return { content: [{ type: 'text' as const, text: 'Rate limit exceeded. Try again in a moment.' }], isError: true };
|
|
186
|
+
}
|
|
187
|
+
const machineId = await getMachineId();
|
|
188
|
+
const signingKey = deriveSigningKey(machineId);
|
|
189
|
+
const skill = await readSkillFile(domain, skillsDir, { verifySignature: true, signingKey });
|
|
157
190
|
if (!skill) {
|
|
158
191
|
return {
|
|
159
192
|
content: [{ type: 'text' as const, text: `No skill file found for "${domain}". Use apitap_capture to capture it first.` }],
|
|
@@ -170,7 +203,6 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
170
203
|
}
|
|
171
204
|
|
|
172
205
|
// Get auth manager for token injection and header auth
|
|
173
|
-
const machineId = await getMachineId();
|
|
174
206
|
const authManager = new AuthManager(APITAP_DIR, machineId);
|
|
175
207
|
|
|
176
208
|
try {
|
|
@@ -256,6 +288,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
256
288
|
},
|
|
257
289
|
},
|
|
258
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
|
+
}
|
|
259
294
|
if (!options._skipSsrfCheck) {
|
|
260
295
|
const validation = await resolveAndValidateUrl(url);
|
|
261
296
|
if (!validation.safe) {
|
|
@@ -272,13 +307,8 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
272
307
|
// In test mode, disable bridge to avoid connecting to real socket
|
|
273
308
|
...(options._skipSsrfCheck ? { _bridgeSocketPath: '/nonexistent' } : {}),
|
|
274
309
|
});
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
return wrapExternalContent(result, 'apitap_browse');
|
|
278
|
-
}
|
|
279
|
-
return {
|
|
280
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
281
|
-
};
|
|
310
|
+
// Always mark as untrusted — failed results may contain attacker-controlled strings (H7 fix)
|
|
311
|
+
return wrapExternalContent(result, 'apitap_browse');
|
|
282
312
|
},
|
|
283
313
|
);
|
|
284
314
|
|
|
@@ -334,6 +364,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
334
364
|
},
|
|
335
365
|
},
|
|
336
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
|
+
}
|
|
337
370
|
try {
|
|
338
371
|
if (!options._skipSsrfCheck) {
|
|
339
372
|
const validation = await resolveAndValidateUrl(url);
|
|
@@ -402,9 +435,7 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
402
435
|
setTimeout(() => reject(new Error('Capture timed out')), timeoutMs),
|
|
403
436
|
),
|
|
404
437
|
]);
|
|
405
|
-
return
|
|
406
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
407
|
-
};
|
|
438
|
+
return wrapExternalContent(result, 'apitap_capture');
|
|
408
439
|
} catch (err: any) {
|
|
409
440
|
try { await session.abort(); } catch { /* already closed */ }
|
|
410
441
|
return {
|
|
@@ -455,9 +486,8 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
455
486
|
});
|
|
456
487
|
const snapshot = await session.start(url);
|
|
457
488
|
sessions.set(session.id, session);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
};
|
|
489
|
+
// Mark as untrusted — snapshot contains external page content (H5 fix)
|
|
490
|
+
return wrapExternalContent({ sessionId: session.id, snapshot }, 'apitap_capture_start');
|
|
461
491
|
} catch (err: any) {
|
|
462
492
|
return {
|
|
463
493
|
content: [{ type: 'text' as const, text: `Failed to start capture session: ${err.message}` }],
|
|
@@ -516,8 +546,10 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
516
546
|
submit,
|
|
517
547
|
});
|
|
518
548
|
|
|
549
|
+
// Mark as untrusted — result contains external page content (H5 fix)
|
|
550
|
+
const wrapped = wrapExternalContent(result, 'apitap_capture_interact');
|
|
519
551
|
return {
|
|
520
|
-
|
|
552
|
+
...wrapped,
|
|
521
553
|
...(result.success ? {} : { isError: true }),
|
|
522
554
|
};
|
|
523
555
|
},
|
|
@@ -558,15 +590,11 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
558
590
|
try {
|
|
559
591
|
if (shouldAbort) {
|
|
560
592
|
await session.abort();
|
|
561
|
-
return {
|
|
562
|
-
content: [{ type: 'text' as const, text: JSON.stringify({ aborted: true, domains: [] }) }],
|
|
563
|
-
};
|
|
593
|
+
return wrapExternalContent({ aborted: true, domains: [] }, 'apitap_capture_finish');
|
|
564
594
|
}
|
|
565
595
|
|
|
566
596
|
const result = await session.finish();
|
|
567
|
-
return
|
|
568
|
-
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
569
|
-
};
|
|
597
|
+
return wrapExternalContent(result, 'apitap_capture_finish');
|
|
570
598
|
} catch (err: any) {
|
|
571
599
|
return {
|
|
572
600
|
content: [{ type: 'text' as const, text: `Finish failed: ${err.message}` }],
|
|
@@ -607,8 +635,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
607
635
|
timeout: timeout ? timeout * 1000 : undefined,
|
|
608
636
|
});
|
|
609
637
|
|
|
638
|
+
const wrapped = wrapExternalContent(result, 'apitap_auth_request');
|
|
610
639
|
return {
|
|
611
|
-
|
|
640
|
+
...wrapped,
|
|
612
641
|
...(result.success ? {} : { isError: true }),
|
|
613
642
|
};
|
|
614
643
|
} catch (err: any) {
|
package/src/native-host.ts
CHANGED
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import net from 'node:net';
|
|
9
9
|
import { signSkillFile } from './skill/signing.js';
|
|
10
|
-
import {
|
|
10
|
+
import { deriveSigningKey } from './auth/crypto.js';
|
|
11
11
|
import { getMachineId } from './auth/manager.js';
|
|
12
12
|
|
|
13
13
|
const SKILLS_DIR = path.join(os.homedir(), '.apitap', 'skills');
|
|
@@ -18,7 +18,7 @@ async function signSkillJson(skillJson: string): Promise<string> {
|
|
|
18
18
|
try {
|
|
19
19
|
const skill = JSON.parse(skillJson);
|
|
20
20
|
const machineId = await getMachineId();
|
|
21
|
-
const key =
|
|
21
|
+
const key = deriveSigningKey(machineId);
|
|
22
22
|
const signed = signSkillFile(skill, key);
|
|
23
23
|
return JSON.stringify(signed);
|
|
24
24
|
} catch {
|
|
@@ -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,6 +58,10 @@ 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
|
+
// 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
|
+
|
|
61
65
|
try {
|
|
62
66
|
const ids = commentIds.join(',');
|
|
63
67
|
const ppUrl = `https://api.pullpush.io/reddit/search/comment/?ids=${ids}`;
|