@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.
Files changed (73) hide show
  1. package/README.md +2 -2
  2. package/dist/auth/crypto.d.ts +10 -0
  3. package/dist/auth/crypto.js +30 -6
  4. package/dist/auth/crypto.js.map +1 -1
  5. package/dist/auth/handoff.js +20 -1
  6. package/dist/auth/handoff.js.map +1 -1
  7. package/dist/auth/manager.d.ts +1 -0
  8. package/dist/auth/manager.js +35 -9
  9. package/dist/auth/manager.js.map +1 -1
  10. package/dist/capture/monitor.js +4 -0
  11. package/dist/capture/monitor.js.map +1 -1
  12. package/dist/capture/scrubber.js +10 -0
  13. package/dist/capture/scrubber.js.map +1 -1
  14. package/dist/capture/session.js +7 -17
  15. package/dist/capture/session.js.map +1 -1
  16. package/dist/cli.js +74 -17
  17. package/dist/cli.js.map +1 -1
  18. package/dist/discovery/fetch.js +3 -3
  19. package/dist/discovery/fetch.js.map +1 -1
  20. package/dist/mcp.d.ts +2 -0
  21. package/dist/mcp.js +59 -33
  22. package/dist/mcp.js.map +1 -1
  23. package/dist/native-host.js +2 -2
  24. package/dist/native-host.js.map +1 -1
  25. package/dist/orchestration/browse.js +13 -4
  26. package/dist/orchestration/browse.js.map +1 -1
  27. package/dist/plugin.d.ts +1 -1
  28. package/dist/plugin.js +14 -4
  29. package/dist/plugin.js.map +1 -1
  30. package/dist/read/decoders/reddit.js +4 -0
  31. package/dist/read/decoders/reddit.js.map +1 -1
  32. package/dist/replay/engine.js +60 -17
  33. package/dist/replay/engine.js.map +1 -1
  34. package/dist/serve.d.ts +2 -0
  35. package/dist/serve.js +8 -1
  36. package/dist/serve.js.map +1 -1
  37. package/dist/skill/generator.d.ts +5 -0
  38. package/dist/skill/generator.js +30 -4
  39. package/dist/skill/generator.js.map +1 -1
  40. package/dist/skill/search.js +1 -1
  41. package/dist/skill/search.js.map +1 -1
  42. package/dist/skill/signing.js +19 -1
  43. package/dist/skill/signing.js.map +1 -1
  44. package/dist/skill/ssrf.js +71 -2
  45. package/dist/skill/ssrf.js.map +1 -1
  46. package/dist/skill/store.d.ts +2 -0
  47. package/dist/skill/store.js +23 -10
  48. package/dist/skill/store.js.map +1 -1
  49. package/dist/skill/validate.d.ts +10 -0
  50. package/dist/skill/validate.js +106 -0
  51. package/dist/skill/validate.js.map +1 -0
  52. package/package.json +1 -1
  53. package/src/auth/crypto.ts +32 -6
  54. package/src/auth/handoff.ts +19 -1
  55. package/src/auth/manager.ts +33 -9
  56. package/src/capture/monitor.ts +4 -0
  57. package/src/capture/scrubber.ts +12 -0
  58. package/src/capture/session.ts +7 -16
  59. package/src/cli.ts +78 -17
  60. package/src/discovery/fetch.ts +2 -2
  61. package/src/mcp.ts +62 -33
  62. package/src/native-host.ts +2 -2
  63. package/src/orchestration/browse.ts +13 -4
  64. package/src/plugin.ts +17 -5
  65. package/src/read/decoders/reddit.ts +4 -0
  66. package/src/replay/engine.ts +70 -17
  67. package/src/serve.ts +10 -1
  68. package/src/skill/generator.ts +32 -4
  69. package/src/skill/search.ts +1 -1
  70. package/src/skill/signing.ts +20 -1
  71. package/src/skill/ssrf.ts +69 -2
  72. package/src/skill/store.ts +32 -11
  73. 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 { deriveKey } from './auth/crypto.js';
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 = deriveKey(machineId);
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 skill = await readSkillFile(domain, SKILLS_DIR);
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: process.env.APITAP_SKIP_SSRF_CHECK === '1',
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 = deriveKey(machineId);
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 skill = await readSkillFile(domain, SKILLS_DIR);
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: process.env.APITAP_SKIP_SSRF_CHECK === '1',
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 { deriveKey } = await import('./auth/crypto.js');
909
+ const { deriveSigningKey } = await import('./auth/crypto.js');
867
910
  const machineId = await getMachineId();
868
- const key = deriveKey(machineId);
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: process.env.APITAP_SKIP_SSRF_CHECK === '1',
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);
@@ -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 = validateUrl(url);
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
- const skill = await readSkillFile(domain, skillsDir);
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
- // Only mark as untrusted if it contains external data
276
- if (result.success && result.data) {
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
- return {
459
- content: [{ type: 'text' as const, text: JSON.stringify({ sessionId: session.id, snapshot }) }],
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
- content: [{ type: 'text' as const, text: JSON.stringify(result) }],
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
- content: [{ type: 'text' as const, text: JSON.stringify(result) }],
640
+ ...wrapped,
612
641
  ...(result.success ? {} : { isError: true }),
613
642
  };
614
643
  } catch (err: any) {
@@ -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 { deriveKey } from './auth/crypto.js';
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 = deriveKey(machineId);
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
- // Save each skill file to disk
67
+ // Sign and save each skill file to disk
65
68
  try {
66
69
  const { writeSkillFile: writeSF } = await import('../skill/store.js');
67
- for (const skill of skillFiles) {
68
- await writeSF(skill, options.skillsDir);
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
- // Save to disk
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 Skip SSRF check for testing only */
24
+ /** @internal Testing onlynever 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}`;