@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/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
|
});
|
|
@@ -569,17 +619,20 @@ export async function replayMultiple(
|
|
|
569
619
|
|
|
570
620
|
const { readSkillFile } = await import('../skill/store.js');
|
|
571
621
|
const { AuthManager, getMachineId } = await import('../auth/manager.js');
|
|
622
|
+
const { deriveSigningKey } = await import('../auth/crypto.js');
|
|
623
|
+
|
|
624
|
+
const machineId = await getMachineId();
|
|
625
|
+
const signingKey = deriveSigningKey(machineId);
|
|
572
626
|
|
|
573
627
|
// Deduplicate skill file reads
|
|
574
628
|
const skillCache = new Map<string, SkillFile | null>();
|
|
575
629
|
const uniqueDomains = [...new Set(requests.map(r => r.domain))];
|
|
576
630
|
await Promise.all(uniqueDomains.map(async (domain) => {
|
|
577
|
-
const skill = await readSkillFile(domain, options.skillsDir);
|
|
631
|
+
const skill = await readSkillFile(domain, options.skillsDir, { verifySignature: true, signingKey });
|
|
578
632
|
skillCache.set(domain, skill);
|
|
579
633
|
}));
|
|
580
634
|
|
|
581
635
|
// Shared auth manager
|
|
582
|
-
const machineId = await getMachineId();
|
|
583
636
|
const authManager = new AuthManager(
|
|
584
637
|
(await import('node:os')).homedir() + '/.apitap',
|
|
585
638
|
machineId,
|
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
|
/**
|
package/src/skill/ssrf.ts
CHANGED
|
@@ -73,8 +73,12 @@ export function validateUrl(urlString: string): ValidationResult {
|
|
|
73
73
|
return { safe: false, reason: `URL targets IPv6 unique-local address: ${hostname}` };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
//
|
|
77
|
-
const
|
|
76
|
+
// Normalize IP representations: decimal integer, octal, hex → dotted-decimal (M17 fix)
|
|
77
|
+
const normalizedIp = normalizeIpv4(hostname);
|
|
78
|
+
const ipToCheck = normalizedIp ?? hostname;
|
|
79
|
+
|
|
80
|
+
// IPv4 private/reserved ranges (M16: added CGNAT, IETF, benchmarking, reserved)
|
|
81
|
+
const ipv4Match = ipToCheck.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
78
82
|
if (ipv4Match) {
|
|
79
83
|
const [, a, b] = ipv4Match.map(Number);
|
|
80
84
|
const first = Number(a);
|
|
@@ -104,11 +108,70 @@ export function validateUrl(urlString: string): ValidationResult {
|
|
|
104
108
|
if (first === 169 && second === 254) {
|
|
105
109
|
return { safe: false, reason: `URL targets link-local address: ${hostname}` };
|
|
106
110
|
}
|
|
111
|
+
// 100.64.0.0/10 — CGNAT (RFC 6598), used in cloud/Tailscale
|
|
112
|
+
if (first === 100 && second >= 64 && second <= 127) {
|
|
113
|
+
return { safe: false, reason: `URL targets CGNAT address: ${hostname}` };
|
|
114
|
+
}
|
|
115
|
+
// 192.0.0.0/24 — IETF Protocol Assignments (RFC 6890)
|
|
116
|
+
if (first === 192 && second === 0 && Number(ipv4Match[3]) === 0) {
|
|
117
|
+
return { safe: false, reason: `URL targets IETF reserved address: ${hostname}` };
|
|
118
|
+
}
|
|
119
|
+
// 198.18.0.0/15 — Benchmarking (RFC 2544)
|
|
120
|
+
if (first === 198 && (second === 18 || second === 19)) {
|
|
121
|
+
return { safe: false, reason: `URL targets benchmarking address: ${hostname}` };
|
|
122
|
+
}
|
|
123
|
+
// 240.0.0.0/4 — Reserved/future use
|
|
124
|
+
if (first >= 240) {
|
|
125
|
+
return { safe: false, reason: `URL targets reserved address: ${hostname}` };
|
|
126
|
+
}
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
return { safe: true };
|
|
110
130
|
}
|
|
111
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Normalize alternative IPv4 representations to dotted-decimal.
|
|
134
|
+
* Handles decimal integer (2130706433), octal (0177.0.0.1), and hex (0x7f.0.0.1).
|
|
135
|
+
* Returns null if hostname is not an IP address or can't be parsed.
|
|
136
|
+
*/
|
|
137
|
+
function normalizeIpv4(hostname: string): string | null {
|
|
138
|
+
// Already standard dotted-decimal
|
|
139
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
|
|
140
|
+
return hostname;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Pure decimal integer (e.g. 2130706433 = 127.0.0.1)
|
|
144
|
+
if (/^\d+$/.test(hostname)) {
|
|
145
|
+
const num = parseInt(hostname, 10);
|
|
146
|
+
if (num >= 0 && num <= 0xFFFFFFFF) {
|
|
147
|
+
return `${(num >>> 24) & 0xFF}.${(num >>> 16) & 0xFF}.${(num >>> 8) & 0xFF}.${num & 0xFF}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Dotted with octal (0-prefixed) or hex (0x-prefixed) octets
|
|
152
|
+
const parts = hostname.split('.');
|
|
153
|
+
if (parts.length === 4) {
|
|
154
|
+
const octets: number[] = [];
|
|
155
|
+
for (const part of parts) {
|
|
156
|
+
let val: number;
|
|
157
|
+
if (/^0x[0-9a-f]+$/i.test(part)) {
|
|
158
|
+
val = parseInt(part, 16);
|
|
159
|
+
} else if (/^0[0-7]+$/.test(part)) {
|
|
160
|
+
val = parseInt(part, 8);
|
|
161
|
+
} else if (/^\d+$/.test(part)) {
|
|
162
|
+
val = parseInt(part, 10);
|
|
163
|
+
} else {
|
|
164
|
+
return null; // Not an IP
|
|
165
|
+
}
|
|
166
|
+
if (val < 0 || val > 255) return null;
|
|
167
|
+
octets.push(val);
|
|
168
|
+
}
|
|
169
|
+
return octets.join('.');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
112
175
|
/**
|
|
113
176
|
* Check if a resolved IP address is in a private/reserved range.
|
|
114
177
|
*/
|
|
@@ -148,6 +211,10 @@ function isPrivateIp(ip: string): string | null {
|
|
|
148
211
|
if (first === 192 && second === 168) return 'private (192.168.x)';
|
|
149
212
|
if (first === 169 && second === 254) return 'link-local';
|
|
150
213
|
if (first === 0) return 'unspecified';
|
|
214
|
+
// M16: additional reserved ranges
|
|
215
|
+
if (first === 100 && second >= 64 && second <= 127) return 'CGNAT (100.64/10)';
|
|
216
|
+
if (first === 198 && (second === 18 || second === 19)) return 'benchmarking (198.18/15)';
|
|
217
|
+
if (first >= 240) return 'reserved (240/4)';
|
|
151
218
|
|
|
152
219
|
return null;
|
|
153
220
|
}
|
package/src/skill/store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir, readdir, access } from 'node:fs/promises';
|
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import type { SkillFile, SkillSummary } from '../types.js';
|
|
6
|
+
import { validateSkillFile } from './validate.js';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_SKILLS_DIR = join(homedir(), '.apitap', 'skills');
|
|
8
9
|
|
|
@@ -36,7 +37,7 @@ export async function writeSkillFile(
|
|
|
36
37
|
skill: SkillFile,
|
|
37
38
|
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
38
39
|
): Promise<string> {
|
|
39
|
-
await mkdir(skillsDir, { recursive: true });
|
|
40
|
+
await mkdir(skillsDir, { recursive: true, mode: 0o700 });
|
|
40
41
|
await ensureGitignore(skillsDir);
|
|
41
42
|
const filePath = skillPath(skill.domain, skillsDir);
|
|
42
43
|
await writeFile(filePath, JSON.stringify(skill, null, 2) + '\n', { mode: 0o600 });
|
|
@@ -46,25 +47,45 @@ export async function writeSkillFile(
|
|
|
46
47
|
export async function readSkillFile(
|
|
47
48
|
domain: string,
|
|
48
49
|
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
49
|
-
options?: {
|
|
50
|
+
options?: {
|
|
51
|
+
verifySignature?: boolean;
|
|
52
|
+
signingKey?: Buffer;
|
|
53
|
+
/** Allow loading unsigned files without throwing. Tampered signed files still reject. */
|
|
54
|
+
trustUnsigned?: boolean;
|
|
55
|
+
}
|
|
50
56
|
): Promise<SkillFile | null> {
|
|
51
57
|
// Validate domain before file I/O — path traversal should throw, not return null
|
|
52
58
|
const path = skillPath(domain, skillsDir);
|
|
53
59
|
try {
|
|
54
60
|
const content = await readFile(path, 'utf-8');
|
|
55
|
-
const
|
|
61
|
+
const raw = JSON.parse(content);
|
|
62
|
+
const skill = validateSkillFile(raw);
|
|
63
|
+
|
|
64
|
+
// Signature verification is ON by default (H1 fix)
|
|
65
|
+
const shouldVerify = options?.verifySignature !== false;
|
|
66
|
+
if (shouldVerify) {
|
|
67
|
+
// Auto-derive signing key if not provided
|
|
68
|
+
let signingKey = options?.signingKey;
|
|
69
|
+
if (!signingKey) {
|
|
70
|
+
const { deriveSigningKey } = await import('../auth/crypto.js');
|
|
71
|
+
const { getMachineId } = await import('../auth/manager.js');
|
|
72
|
+
const machineId = await getMachineId();
|
|
73
|
+
signingKey = deriveSigningKey(machineId);
|
|
74
|
+
}
|
|
56
75
|
|
|
57
|
-
// If verification requested, check signature
|
|
58
|
-
if (options?.verifySignature && options.signingKey) {
|
|
59
76
|
if (skill.provenance === 'imported') {
|
|
60
|
-
// Imported files had foreign signature stripped — can't verify
|
|
61
|
-
// Future: re-sign on import with local key
|
|
77
|
+
// Imported files had foreign signature stripped — can't verify
|
|
62
78
|
} else if (!skill.signature) {
|
|
63
|
-
//
|
|
64
|
-
|
|
79
|
+
// Unsigned files are rejected unless trustUnsigned is set
|
|
80
|
+
if (!options?.trustUnsigned) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Skill file for ${domain} is unsigned and cannot be verified. ` +
|
|
83
|
+
`Re-capture or re-import the skill file, or use --trust-unsigned to load it.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
65
86
|
} else {
|
|
66
87
|
const { verifySignature } = await import('./signing.js');
|
|
67
|
-
if (!verifySignature(skill,
|
|
88
|
+
if (!verifySignature(skill, signingKey)) {
|
|
68
89
|
throw new Error(`Skill file signature verification failed for ${domain} — file may be tampered`);
|
|
69
90
|
}
|
|
70
91
|
}
|
|
@@ -93,7 +114,7 @@ export async function listSkillFiles(
|
|
|
93
114
|
if (!file.endsWith('.json')) continue;
|
|
94
115
|
const domain = file.replace(/\.json$/, '');
|
|
95
116
|
if (!DOMAIN_RE.test(domain)) continue; // skip non-conforming filenames
|
|
96
|
-
const skill = await readSkillFile(domain, skillsDir);
|
|
117
|
+
const skill = await readSkillFile(domain, skillsDir, { trustUnsigned: true });
|
|
97
118
|
if (skill) {
|
|
98
119
|
summaries.push({
|
|
99
120
|
domain: skill.domain,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/skill/validate.ts
|
|
2
|
+
import type { SkillFile } from '../types.js';
|
|
3
|
+
import { validateUrl } from './ssrf.js';
|
|
4
|
+
|
|
5
|
+
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate a parsed JSON object as a SkillFile.
|
|
9
|
+
* Throws on invalid input — fail fast, fail loud.
|
|
10
|
+
* SSRF checks are optional here (default: off) because the replay engine
|
|
11
|
+
* already enforces SSRF at request time with DNS resolution.
|
|
12
|
+
*/
|
|
13
|
+
export function validateSkillFile(raw: unknown, options?: { checkSsrf?: boolean }): SkillFile {
|
|
14
|
+
if (!raw || typeof raw !== 'object') {
|
|
15
|
+
throw new Error('Skill file must be an object');
|
|
16
|
+
}
|
|
17
|
+
const obj = raw as Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
// domain
|
|
20
|
+
if (typeof obj.domain !== 'string' || obj.domain.length === 0 || obj.domain.length > 253) {
|
|
21
|
+
throw new Error('Invalid domain: must be a string of 1-253 characters');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// baseUrl — must be a valid URL; SSRF checked at replay time
|
|
25
|
+
if (typeof obj.baseUrl !== 'string') {
|
|
26
|
+
throw new Error('Missing baseUrl');
|
|
27
|
+
}
|
|
28
|
+
let baseUrlHostname: string;
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(obj.baseUrl);
|
|
31
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
32
|
+
throw new Error('non-HTTP scheme');
|
|
33
|
+
}
|
|
34
|
+
baseUrlHostname = url.hostname;
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`Invalid baseUrl: must be a valid HTTP(S) URL`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Domain-lock: baseUrl hostname must match or be a subdomain of domain (C1 fix)
|
|
40
|
+
const domainStr = obj.domain as string;
|
|
41
|
+
if (baseUrlHostname !== domainStr && !baseUrlHostname.endsWith('.' + domainStr)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`baseUrl hostname "${baseUrlHostname}" does not match domain "${domainStr}". ` +
|
|
44
|
+
`Skill files cannot redirect requests to unrelated hosts.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (options?.checkSsrf) {
|
|
49
|
+
const ssrf = validateUrl(obj.baseUrl);
|
|
50
|
+
if (!ssrf.safe) {
|
|
51
|
+
throw new Error(`Unsafe baseUrl: ${ssrf.reason}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// endpoints
|
|
56
|
+
if (!Array.isArray(obj.endpoints)) {
|
|
57
|
+
throw new Error('Missing or invalid endpoints array');
|
|
58
|
+
}
|
|
59
|
+
if (obj.endpoints.length > 500) {
|
|
60
|
+
throw new Error('Too many endpoints (max 500)');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < obj.endpoints.length; i++) {
|
|
64
|
+
const ep = obj.endpoints[i];
|
|
65
|
+
if (!ep || typeof ep !== 'object') {
|
|
66
|
+
throw new Error(`Endpoint ${i}: must be an object`);
|
|
67
|
+
}
|
|
68
|
+
const e = ep as Record<string, unknown>;
|
|
69
|
+
if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 200) {
|
|
70
|
+
throw new Error(`Endpoint ${i}: id must be a string of 1-200 characters`);
|
|
71
|
+
}
|
|
72
|
+
if (typeof e.method !== 'string' || !ALLOWED_METHODS.has(e.method)) {
|
|
73
|
+
throw new Error(`Endpoint ${i}: method must be one of ${[...ALLOWED_METHODS].join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof e.path !== 'string' || !e.path.startsWith('/')) {
|
|
76
|
+
throw new Error(`Endpoint ${i}: path must start with /`);
|
|
77
|
+
}
|
|
78
|
+
if (e.path.length > 2000) {
|
|
79
|
+
throw new Error(`Endpoint ${i}: path exceeds 2000 characters`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// M11: Deep type validation on nested structures
|
|
83
|
+
if ('headers' in e && e.headers !== undefined) {
|
|
84
|
+
if (typeof e.headers !== 'object' || e.headers === null || Array.isArray(e.headers)) {
|
|
85
|
+
throw new Error(`Endpoint ${i}: headers must be an object`);
|
|
86
|
+
}
|
|
87
|
+
for (const [hk, hv] of Object.entries(e.headers as Record<string, unknown>)) {
|
|
88
|
+
if (typeof hv !== 'string') {
|
|
89
|
+
throw new Error(`Endpoint ${i}: header "${hk}" value must be a string`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if ('queryParams' in e && e.queryParams !== undefined) {
|
|
95
|
+
if (typeof e.queryParams !== 'object' || e.queryParams === null || Array.isArray(e.queryParams)) {
|
|
96
|
+
throw new Error(`Endpoint ${i}: queryParams must be an object`);
|
|
97
|
+
}
|
|
98
|
+
for (const [qk, qv] of Object.entries(e.queryParams as Record<string, unknown>)) {
|
|
99
|
+
if (typeof qv !== 'object' || qv === null || typeof (qv as any).example !== 'string') {
|
|
100
|
+
throw new Error(`Endpoint ${i}: queryParam "${qk}" must have a string example`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if ('requestBody' in e && e.requestBody !== undefined) {
|
|
106
|
+
const rb = e.requestBody as Record<string, unknown>;
|
|
107
|
+
if (typeof rb !== 'object' || rb === null) {
|
|
108
|
+
throw new Error(`Endpoint ${i}: requestBody must be an object`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof rb.contentType !== 'string') {
|
|
111
|
+
throw new Error(`Endpoint ${i}: requestBody.contentType must be a string`);
|
|
112
|
+
}
|
|
113
|
+
if (rb.variables !== undefined && !Array.isArray(rb.variables)) {
|
|
114
|
+
throw new Error(`Endpoint ${i}: requestBody.variables must be an array`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return raw as SkillFile;
|
|
120
|
+
}
|