@apitap/core 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/auth/crypto.d.ts +10 -0
- package/dist/auth/crypto.js +30 -6
- package/dist/auth/crypto.js.map +1 -1
- package/dist/auth/handoff.js +20 -1
- package/dist/auth/handoff.js.map +1 -1
- package/dist/auth/manager.d.ts +1 -0
- package/dist/auth/manager.js +35 -9
- package/dist/auth/manager.js.map +1 -1
- package/dist/capture/monitor.js +4 -0
- package/dist/capture/monitor.js.map +1 -1
- package/dist/capture/scrubber.js +10 -0
- package/dist/capture/scrubber.js.map +1 -1
- package/dist/capture/session.js +7 -17
- package/dist/capture/session.js.map +1 -1
- package/dist/cli.js +74 -17
- package/dist/cli.js.map +1 -1
- package/dist/discovery/fetch.js +3 -3
- package/dist/discovery/fetch.js.map +1 -1
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +59 -33
- package/dist/mcp.js.map +1 -1
- package/dist/native-host.js +2 -2
- package/dist/native-host.js.map +1 -1
- package/dist/orchestration/browse.js +13 -4
- package/dist/orchestration/browse.js.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +14 -4
- package/dist/plugin.js.map +1 -1
- package/dist/read/decoders/reddit.js +4 -0
- package/dist/read/decoders/reddit.js.map +1 -1
- package/dist/replay/engine.js +60 -17
- package/dist/replay/engine.js.map +1 -1
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +8 -1
- package/dist/serve.js.map +1 -1
- package/dist/skill/generator.d.ts +5 -0
- package/dist/skill/generator.js +30 -4
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/search.js +1 -1
- package/dist/skill/search.js.map +1 -1
- package/dist/skill/signing.js +19 -1
- package/dist/skill/signing.js.map +1 -1
- package/dist/skill/ssrf.js +71 -2
- package/dist/skill/ssrf.js.map +1 -1
- package/dist/skill/store.d.ts +2 -0
- package/dist/skill/store.js +23 -10
- package/dist/skill/store.js.map +1 -1
- package/dist/skill/validate.d.ts +10 -0
- package/dist/skill/validate.js +106 -0
- package/dist/skill/validate.js.map +1 -0
- package/package.json +1 -1
- package/src/auth/crypto.ts +14 -6
- package/src/auth/handoff.ts +19 -1
- package/src/auth/manager.ts +22 -5
- package/src/capture/monitor.ts +4 -0
- package/src/capture/scrubber.ts +12 -0
- package/src/capture/session.ts +5 -14
- package/src/cli.ts +71 -11
- package/src/discovery/fetch.ts +2 -2
- package/src/mcp.ts +58 -31
- package/src/orchestration/browse.ts +13 -4
- package/src/plugin.ts +17 -5
- package/src/read/decoders/reddit.ts +3 -3
- package/src/replay/engine.ts +65 -15
- package/src/serve.ts +10 -1
- package/src/skill/generator.ts +32 -4
- package/src/skill/search.ts +1 -1
- package/src/skill/signing.ts +20 -1
- package/src/skill/ssrf.ts +69 -2
- package/src/skill/store.ts +29 -11
- package/src/skill/validate.ts +48 -0
package/src/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
|
@@ -37,7 +37,7 @@ export async function writeSkillFile(
|
|
|
37
37
|
skill: SkillFile,
|
|
38
38
|
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
39
39
|
): Promise<string> {
|
|
40
|
-
await mkdir(skillsDir, { recursive: true });
|
|
40
|
+
await mkdir(skillsDir, { recursive: true, mode: 0o700 });
|
|
41
41
|
await ensureGitignore(skillsDir);
|
|
42
42
|
const filePath = skillPath(skill.domain, skillsDir);
|
|
43
43
|
await writeFile(filePath, JSON.stringify(skill, null, 2) + '\n', { mode: 0o600 });
|
|
@@ -47,7 +47,12 @@ export async function writeSkillFile(
|
|
|
47
47
|
export async function readSkillFile(
|
|
48
48
|
domain: string,
|
|
49
49
|
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
50
|
-
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
|
+
}
|
|
51
56
|
): Promise<SkillFile | null> {
|
|
52
57
|
// Validate domain before file I/O — path traversal should throw, not return null
|
|
53
58
|
const path = skillPath(domain, skillsDir);
|
|
@@ -56,18 +61,31 @@ export async function readSkillFile(
|
|
|
56
61
|
const raw = JSON.parse(content);
|
|
57
62
|
const skill = validateSkillFile(raw);
|
|
58
63
|
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
+
}
|
|
75
|
+
|
|
61
76
|
if (skill.provenance === 'imported') {
|
|
62
|
-
// Imported files had foreign signature stripped — can't verify
|
|
63
|
-
// Future: re-sign on import with local key
|
|
77
|
+
// Imported files had foreign signature stripped — can't verify
|
|
64
78
|
} else if (!skill.signature) {
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
86
|
} else {
|
|
69
87
|
const { verifySignature } = await import('./signing.js');
|
|
70
|
-
if (!verifySignature(skill,
|
|
88
|
+
if (!verifySignature(skill, signingKey)) {
|
|
71
89
|
throw new Error(`Skill file signature verification failed for ${domain} — file may be tampered`);
|
|
72
90
|
}
|
|
73
91
|
}
|
|
@@ -96,7 +114,7 @@ export async function listSkillFiles(
|
|
|
96
114
|
if (!file.endsWith('.json')) continue;
|
|
97
115
|
const domain = file.replace(/\.json$/, '');
|
|
98
116
|
if (!DOMAIN_RE.test(domain)) continue; // skip non-conforming filenames
|
|
99
|
-
const skill = await readSkillFile(domain, skillsDir);
|
|
117
|
+
const skill = await readSkillFile(domain, skillsDir, { trustUnsigned: true });
|
|
100
118
|
if (skill) {
|
|
101
119
|
summaries.push({
|
|
102
120
|
domain: skill.domain,
|
package/src/skill/validate.ts
CHANGED
|
@@ -25,14 +25,26 @@ export function validateSkillFile(raw: unknown, options?: { checkSsrf?: boolean
|
|
|
25
25
|
if (typeof obj.baseUrl !== 'string') {
|
|
26
26
|
throw new Error('Missing baseUrl');
|
|
27
27
|
}
|
|
28
|
+
let baseUrlHostname: string;
|
|
28
29
|
try {
|
|
29
30
|
const url = new URL(obj.baseUrl);
|
|
30
31
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
31
32
|
throw new Error('non-HTTP scheme');
|
|
32
33
|
}
|
|
34
|
+
baseUrlHostname = url.hostname;
|
|
33
35
|
} catch {
|
|
34
36
|
throw new Error(`Invalid baseUrl: must be a valid HTTP(S) URL`);
|
|
35
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
|
+
|
|
36
48
|
if (options?.checkSsrf) {
|
|
37
49
|
const ssrf = validateUrl(obj.baseUrl);
|
|
38
50
|
if (!ssrf.safe) {
|
|
@@ -66,6 +78,42 @@ export function validateSkillFile(raw: unknown, options?: { checkSsrf?: boolean
|
|
|
66
78
|
if (e.path.length > 2000) {
|
|
67
79
|
throw new Error(`Endpoint ${i}: path exceeds 2000 characters`);
|
|
68
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
|
+
}
|
|
69
117
|
}
|
|
70
118
|
|
|
71
119
|
return raw as SkillFile;
|