codeep 3.3.2 → 3.3.3
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/dist/config/index.d.ts +8 -2
- package/dist/config/index.js +14 -6
- package/dist/renderer/commands.js +28 -16
- package/dist/utils/guardedFetch.d.ts +29 -0
- package/dist/utils/guardedFetch.js +67 -0
- package/dist/utils/shell.js +148 -20
- package/dist/utils/ssrfGuard.d.ts +25 -2
- package/dist/utils/ssrfGuard.js +125 -47
- package/dist/utils/toolExecution.js +7 -12
- package/dist/utils/webFetch.js +39 -28
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/config/index.d.ts
CHANGED
|
@@ -241,9 +241,15 @@ export declare function isKeySyncEnabled(): boolean;
|
|
|
241
241
|
*/
|
|
242
242
|
export declare function keySyncForcedOffByEnv(): boolean;
|
|
243
243
|
/**
|
|
244
|
-
* Clear API key for a specific provider
|
|
244
|
+
* Clear API key for a specific provider.
|
|
245
|
+
*
|
|
246
|
+
* Resolves `true` once the key is gone from secure storage. A keychain that
|
|
247
|
+
* refuses the delete is swallowed a layer down (keychain.ts logs it at debug),
|
|
248
|
+
* so the outcome is verified by reading the key back: when it is still there
|
|
249
|
+
* nothing is changed and this resolves `false` — the caller has to say so
|
|
250
|
+
* rather than report a logout that left the key on disk.
|
|
245
251
|
*/
|
|
246
|
-
export declare function clearApiKey(providerId: string): Promise<
|
|
252
|
+
export declare function clearApiKey(providerId: string): Promise<boolean>;
|
|
247
253
|
export declare function isConfiguredAsync(providerId?: string): Promise<boolean>;
|
|
248
254
|
export declare function isConfigured(providerId?: string): boolean;
|
|
249
255
|
export declare function getCurrentProvider(): {
|
package/dist/config/index.js
CHANGED
|
@@ -612,17 +612,25 @@ export function keySyncForcedOffByEnv() {
|
|
|
612
612
|
return envForcesKeySyncOff();
|
|
613
613
|
}
|
|
614
614
|
/**
|
|
615
|
-
* Clear API key for a specific provider
|
|
615
|
+
* Clear API key for a specific provider.
|
|
616
|
+
*
|
|
617
|
+
* Resolves `true` once the key is gone from secure storage. A keychain that
|
|
618
|
+
* refuses the delete is swallowed a layer down (keychain.ts logs it at debug),
|
|
619
|
+
* so the outcome is verified by reading the key back: when it is still there
|
|
620
|
+
* nothing is changed and this resolves `false` — the caller has to say so
|
|
621
|
+
* rather than report a logout that left the key on disk.
|
|
616
622
|
*/
|
|
617
623
|
export async function clearApiKey(providerId) {
|
|
618
|
-
|
|
619
|
-
apiKeyCache.delete(providerId);
|
|
620
|
-
// Clear from secure storage + the non-secret index
|
|
624
|
+
const store = secureKeyStore();
|
|
621
625
|
try {
|
|
622
|
-
await
|
|
626
|
+
await store.deleteApiKey(providerId);
|
|
623
627
|
}
|
|
624
|
-
catch { /*
|
|
628
|
+
catch { /* verified below */ }
|
|
629
|
+
if (await store.hasApiKey(providerId))
|
|
630
|
+
return false;
|
|
631
|
+
apiKeyCache.delete(providerId);
|
|
625
632
|
removeConfiguredProviderId(providerId);
|
|
633
|
+
return true;
|
|
626
634
|
}
|
|
627
635
|
export async function isConfiguredAsync(providerId) {
|
|
628
636
|
const key = await loadApiKey(providerId);
|
|
@@ -1142,27 +1142,39 @@ Format: use headers per category, only include categories where you found issues
|
|
|
1142
1142
|
ctx.app.notify('No providers configured');
|
|
1143
1143
|
return;
|
|
1144
1144
|
}
|
|
1145
|
-
ctx.app.showLogoutPicker(configuredProviders, (result) => {
|
|
1145
|
+
ctx.app.showLogoutPicker(configuredProviders, async (result) => {
|
|
1146
1146
|
if (result === null)
|
|
1147
1147
|
return;
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1148
|
+
const targets = result === 'all' ? configuredProviders : configuredProviders.filter(p => p.id === result);
|
|
1149
|
+
const removed = [];
|
|
1150
|
+
const kept = [];
|
|
1151
|
+
// Awaited one by one: reporting "Logged out" before the keychain
|
|
1152
|
+
// answered is how a refused delete used to pass for a logout.
|
|
1153
|
+
for (const p of targets) {
|
|
1154
|
+
if (await clearApiKey(p.id))
|
|
1155
|
+
removed.push(p);
|
|
1156
|
+
else
|
|
1157
|
+
kept.push(p.name);
|
|
1158
|
+
}
|
|
1159
|
+
if (kept.length > 0) {
|
|
1160
|
+
ctx.app.notify(`Could not remove the stored key for ${kept.join(', ')} — still logged in. Delete the "codeep" item from your system keychain, then run /logout again.`);
|
|
1161
|
+
}
|
|
1162
|
+
if (removed.length === 0)
|
|
1163
|
+
return;
|
|
1164
|
+
if (result === 'all' && kept.length === 0) {
|
|
1151
1165
|
ctx.app.notify('Logged out from all providers. Use /login to sign in.');
|
|
1152
1166
|
}
|
|
1153
1167
|
else {
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
ctx.app.notify('No providers configured. Use /login to sign in.');
|
|
1165
|
-
}
|
|
1168
|
+
ctx.app.notify(`Logged out from ${removed.map(p => p.name).join(', ')}`);
|
|
1169
|
+
}
|
|
1170
|
+
if (removed.some(p => p.id === currentProvider.id)) {
|
|
1171
|
+
const remaining = configuredProviders.filter(p => !removed.includes(p));
|
|
1172
|
+
if (remaining.length > 0) {
|
|
1173
|
+
setProvider(remaining[0].id);
|
|
1174
|
+
ctx.app.notify(`Switched to ${remaining[0].name}`);
|
|
1175
|
+
}
|
|
1176
|
+
else if (result !== 'all') {
|
|
1177
|
+
ctx.app.notify('No providers configured. Use /login to sign in.');
|
|
1166
1178
|
}
|
|
1167
1179
|
}
|
|
1168
1180
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch_url`'s transport: curl, with redirects followed here rather than by
|
|
3
|
+
* curl, so every hop passes the SSRF guard.
|
|
4
|
+
*
|
|
5
|
+
* `curl -L` only let us check the URL the model asked for. A public page that
|
|
6
|
+
* answers `302 Location: http://169.254.169.254/…` (or any LAN/localhost
|
|
7
|
+
* service) was followed silently and its body handed back to the model —
|
|
8
|
+
* and `fetch_url` runs without a confirmation prompt. So each hop is resolved,
|
|
9
|
+
* checked, and the connection pinned to the checked address with `--resolve`,
|
|
10
|
+
* which also stops a second DNS answer (rebinding) from swapping in a private
|
|
11
|
+
* address between the check and curl's own lookup.
|
|
12
|
+
*/
|
|
13
|
+
export type CurlRunner = (args: string[]) => Promise<{
|
|
14
|
+
success: boolean;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}>;
|
|
18
|
+
export type GuardedFetchResult = {
|
|
19
|
+
ok: true;
|
|
20
|
+
body: string;
|
|
21
|
+
finalUrl: string;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
export declare const MAX_FETCH_REDIRECTS = 5;
|
|
27
|
+
/** One budget for the whole chain — per hop, six slow 302s would hold a tool call for minutes. */
|
|
28
|
+
export declare const FETCH_BUDGET_MS = 30000;
|
|
29
|
+
export declare function fetchUrlGuarded(rawUrl: string, runCurl: CurlRunner, maxRedirects?: number): Promise<GuardedFetchResult>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch_url`'s transport: curl, with redirects followed here rather than by
|
|
3
|
+
* curl, so every hop passes the SSRF guard.
|
|
4
|
+
*
|
|
5
|
+
* `curl -L` only let us check the URL the model asked for. A public page that
|
|
6
|
+
* answers `302 Location: http://169.254.169.254/…` (or any LAN/localhost
|
|
7
|
+
* service) was followed silently and its body handed back to the model —
|
|
8
|
+
* and `fetch_url` runs without a confirmation prompt. So each hop is resolved,
|
|
9
|
+
* checked, and the connection pinned to the checked address with `--resolve`,
|
|
10
|
+
* which also stops a second DNS answer (rebinding) from swapping in a private
|
|
11
|
+
* address between the check and curl's own lookup.
|
|
12
|
+
*/
|
|
13
|
+
import { resolveFetchTarget } from './ssrfGuard.js';
|
|
14
|
+
export const MAX_FETCH_REDIRECTS = 5;
|
|
15
|
+
/** One budget for the whole chain — per hop, six slow 302s would hold a tool call for minutes. */
|
|
16
|
+
export const FETCH_BUDGET_MS = 30_000;
|
|
17
|
+
// Appended by curl after the body; the LAST occurrence is always curl's own,
|
|
18
|
+
// so a page that happens to contain the marker can't forge a redirect.
|
|
19
|
+
const META_MARKER = '\n__CODEEP_FETCH_META__';
|
|
20
|
+
export async function fetchUrlGuarded(rawUrl, runCurl, maxRedirects = MAX_FETCH_REDIRECTS) {
|
|
21
|
+
let current = rawUrl;
|
|
22
|
+
const deadline = Date.now() + FETCH_BUDGET_MS;
|
|
23
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
24
|
+
const secondsLeft = Math.ceil((deadline - Date.now()) / 1000);
|
|
25
|
+
if (secondsLeft <= 0)
|
|
26
|
+
return { ok: false, error: `Timed out after ${FETCH_BUDGET_MS / 1000}s following redirects` };
|
|
27
|
+
const check = await resolveFetchTarget(current);
|
|
28
|
+
if (!check.ok) {
|
|
29
|
+
return { ok: false, error: hop === 0 ? check.reason : `Refused redirect to ${current} — ${check.reason}` };
|
|
30
|
+
}
|
|
31
|
+
if (check.unresolved) {
|
|
32
|
+
// An unpinned request would let curl's own lookup decide the address.
|
|
33
|
+
return { ok: false, error: `Could not resolve host: ${check.url.hostname}` };
|
|
34
|
+
}
|
|
35
|
+
const { url, addresses } = check;
|
|
36
|
+
const args = [
|
|
37
|
+
'-s',
|
|
38
|
+
'--proto', '=http,https',
|
|
39
|
+
'-m', String(secondsLeft),
|
|
40
|
+
'-A', 'Codeep/1.0',
|
|
41
|
+
'--max-filesize', '1000000',
|
|
42
|
+
'-w', `${META_MARKER}%{http_code} %{redirect_url}`,
|
|
43
|
+
];
|
|
44
|
+
if (addresses?.length) {
|
|
45
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
46
|
+
const pinned = addresses.map((a) => (a.includes(':') ? `[${a}]` : a)).join(',');
|
|
47
|
+
args.push('--resolve', `${url.hostname}:${port}:${pinned}`);
|
|
48
|
+
}
|
|
49
|
+
args.push(url.href);
|
|
50
|
+
const res = await runCurl(args);
|
|
51
|
+
const at = res.stdout.lastIndexOf(META_MARKER);
|
|
52
|
+
if (!res.success || at === -1) {
|
|
53
|
+
return { ok: false, error: res.stderr || 'Failed to fetch URL' };
|
|
54
|
+
}
|
|
55
|
+
const body = res.stdout.slice(0, at);
|
|
56
|
+
const meta = res.stdout.slice(at + META_MARKER.length).trim();
|
|
57
|
+
const space = meta.indexOf(' ');
|
|
58
|
+
const status = Number(space === -1 ? meta : meta.slice(0, space));
|
|
59
|
+
const location = space === -1 ? '' : meta.slice(space + 1).trim();
|
|
60
|
+
if (status >= 300 && status < 400 && location) {
|
|
61
|
+
current = location;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, body, finalUrl: url.href };
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: `Too many redirects (more than ${maxRedirects})` };
|
|
67
|
+
}
|
package/dist/utils/shell.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { spawnSync, spawn } from 'child_process';
|
|
5
5
|
import { resolve, relative, isAbsolute } from 'path';
|
|
6
6
|
import { existsSync } from 'fs';
|
|
7
|
-
import {
|
|
7
|
+
import { isIP } from 'net';
|
|
8
|
+
import { assertFetchUrlAllowed, isBlockedIp } from './ssrfGuard.js';
|
|
8
9
|
// Dangerous command patterns that should never be executed
|
|
9
10
|
const BLOCKED_COMMANDS = new Set([
|
|
10
11
|
'sudo',
|
|
@@ -116,37 +117,159 @@ function hasInlineEval(command, args) {
|
|
|
116
117
|
// routes through assertFetchUrlAllowed; without this list the same model-
|
|
117
118
|
// controlled URL could just be passed to curl instead.
|
|
118
119
|
const URL_CARRYING_COMMANDS = new Set(['curl', 'wget', 'http', 'https']);
|
|
120
|
+
// curl options that consume the next argument as their value — generated
|
|
121
|
+
// from `curl --help all` (every entry with a <value>), plus the proxy flags
|
|
122
|
+
// whose value is shown without brackets. Knowing them is what lets a bare
|
|
123
|
+
// number be read as a host: `curl 2130706433:8080` connects to 127.0.0.1,
|
|
124
|
+
// while the `30` in `curl -m 30 …` is a timeout.
|
|
125
|
+
const CURL_VALUE_OPTIONS = new Set(('--abstract-unix-socket --alt-svc --aws-sigv4 --cacert --capath --cert --cert-type --ciphers --config ' +
|
|
126
|
+
'--connect-timeout --connect-to --continue-at --cookie --cookie-jar --create-file-mode --crlfile --curves ' +
|
|
127
|
+
'--data --data-ascii --data-binary --data-raw --data-urlencode --delegation --dns-interface --dns-ipv4-addr ' +
|
|
128
|
+
'--dns-ipv6-addr --dns-servers --doh-url --dump-header --egd-file --engine --etag-compare --etag-save ' +
|
|
129
|
+
'--expect100-timeout --form --form-string --ftp-account --ftp-alternative-to-user --ftp-method --ftp-port ' +
|
|
130
|
+
'--ftp-ssl-ccc-mode --happy-eyeballs-timeout-ms --haproxy-clientip --header --hostpubmd5 --hostpubsha256 ' +
|
|
131
|
+
'--hsts --interface --ipfs-gateway --json --keepalive-time --key --key-type --krb --libcurl --limit-rate ' +
|
|
132
|
+
'--local-port --login-options --mail-auth --mail-from --mail-rcpt --max-filesize --max-redirs --max-time ' +
|
|
133
|
+
'--netrc-file --noproxy --oauth2-bearer --output --output-dir --parallel-max --pass --pinnedpubkey --proto ' +
|
|
134
|
+
'--proto-default --proto-redir --proxy-cacert --proxy-capath --proxy-cert --proxy-cert-type --proxy-ciphers ' +
|
|
135
|
+
'--proxy-crlfile --proxy-header --proxy-key --proxy-key-type --proxy-pass --proxy-pinnedpubkey ' +
|
|
136
|
+
'--proxy-service-name --proxy-tls13-ciphers --proxy-tlsauthtype --proxy-tlspassword --proxy-tlsuser ' +
|
|
137
|
+
'--proxy-user --proxy1.0 --pubkey --quote --random-file --range --rate --referer --request --request-target ' +
|
|
138
|
+
'--resolve --retry --retry-delay --retry-max-time --sasl-authzid --service-name --socks4 --socks4a --socks5 ' +
|
|
139
|
+
'--socks5-gssapi-service --socks5-hostname --speed-limit --speed-time --stderr --telnet-option --tftp-blksize ' +
|
|
140
|
+
'--time-cond --tls-max --tls13-ciphers --tlsauthtype --tlspassword --tlsuser --trace --trace-ascii ' +
|
|
141
|
+
'--trace-config --unix-socket --upload-file --url --url-query --user --user-agent --variable --write-out ' +
|
|
142
|
+
'--proxy --preproxy').split(' '));
|
|
143
|
+
const CURL_VALUE_SHORT = new Set('ACDEFHKPQTUXYbcdemortuwxyz'.split(''));
|
|
144
|
+
// wget spells most values `--opt=value`; these are the ones commonly split.
|
|
145
|
+
const WGET_VALUE_OPTIONS = new Set(['-O', '-o', '-a', '-t', '-T', '-w', '-e', '-P', '-U', '-Q', '-l', '-A', '-R', '-D', '-X', '-I', '-i', '-B',
|
|
146
|
+
'--output-document', '--output-file', '--tries', '--timeout', '--wait', '--execute', '--directory-prefix',
|
|
147
|
+
'--user-agent', '--header', '--user', '--password', '--input-file', '--base', '--limit-rate', '--max-redirect']);
|
|
148
|
+
/** True when `arg` (an option) makes the NEXT argument its value. */
|
|
149
|
+
function optionTakesNextArg(command, arg) {
|
|
150
|
+
if (arg.includes('='))
|
|
151
|
+
return false;
|
|
152
|
+
if (command === 'curl') {
|
|
153
|
+
if (arg.startsWith('--'))
|
|
154
|
+
return CURL_VALUE_OPTIONS.has(arg);
|
|
155
|
+
// Short cluster: `-sm 30` — only the last letter may take the next arg;
|
|
156
|
+
// an earlier value-taking letter swallows the rest of the cluster (`-m30`).
|
|
157
|
+
for (let i = 1; i < arg.length; i++) {
|
|
158
|
+
if (CURL_VALUE_SHORT.has(arg[i]))
|
|
159
|
+
return i === arg.length - 1;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (command === 'wget')
|
|
164
|
+
return WGET_VALUE_OPTIONS.has(arg);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const PORT_PATH = String.raw `(:\d+)?([\/?#].*)?`;
|
|
168
|
+
// Scheme-less host forms the tools accept: localhost, dotted/bracketed IP
|
|
169
|
+
// literals, named hosts, and the numeric spellings libc resolves —
|
|
170
|
+
// `2130706433`, `0x7f000001`, `017700000001`, `0` are all 127.0.0.1/0.0.0.0.
|
|
171
|
+
const SCHEMELESS_HOST = new RegExp('^(' + [
|
|
172
|
+
`localhost${PORT_PATH}`,
|
|
173
|
+
String.raw `\[[0-9a-f:.%]+\]` + PORT_PATH,
|
|
174
|
+
String.raw `(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+)){0,3}` + PORT_PATH,
|
|
175
|
+
String.raw `[a-z0-9-]+(\.[a-z0-9-]+)+` + PORT_PATH,
|
|
176
|
+
String.raw `[a-z0-9-]+:\d+([\/?#].*)?`,
|
|
177
|
+
].join('|') + ')$', 'i');
|
|
119
178
|
// Heuristic: extract URL-looking arguments. curl/wget accept URLs with or
|
|
120
179
|
// without a scheme (curl example.com works), and URLs may also ride in
|
|
121
|
-
// option values (`--url
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
function extractUrlCandidates(args) {
|
|
180
|
+
// option values (`--url …`). Scheme-less hosts are normalized so the guard
|
|
181
|
+
// sees what the tool will actually connect to; option values are skipped so
|
|
182
|
+
// a timeout or a data payload isn't mistaken for a host.
|
|
183
|
+
function extractUrlCandidates(command, args) {
|
|
125
184
|
const urls = [];
|
|
126
|
-
for (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
for (let i = 0; i < args.length; i++) {
|
|
186
|
+
const arg = args[i];
|
|
187
|
+
if (arg.startsWith('-') && arg.length > 1) {
|
|
188
|
+
const eq = arg.match(/^--url=(.+)$/i);
|
|
189
|
+
if (eq) {
|
|
190
|
+
urls.push(eq[1]);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (optionTakesNextArg(command, arg)) {
|
|
194
|
+
if (arg === '--url' && args[i + 1])
|
|
195
|
+
urls.push(args[i + 1]);
|
|
196
|
+
i++; // the value is not a positional URL
|
|
197
|
+
}
|
|
134
198
|
continue;
|
|
135
199
|
}
|
|
136
200
|
if (/^https?:\/\//i.test(arg)) {
|
|
137
201
|
urls.push(arg);
|
|
138
202
|
}
|
|
139
|
-
else if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// scheme-less host or host/path — what curl will connect to
|
|
203
|
+
else if ((command === 'http' || command === 'https') && /^:\d*([\/?#].*)?$/.test(arg)) {
|
|
204
|
+
// httpie shorthand: `http :3000/api` is localhost:3000
|
|
205
|
+
urls.push(`http://localhost${arg}`);
|
|
206
|
+
}
|
|
207
|
+
else if (SCHEMELESS_HOST.test(arg)) {
|
|
145
208
|
urls.push(`http://${arg}`);
|
|
146
209
|
}
|
|
147
210
|
}
|
|
148
211
|
return urls;
|
|
149
212
|
}
|
|
213
|
+
// curl options that change WHERE the connection goes without changing the
|
|
214
|
+
// URL the guard looks at: `--resolve example.com:80:127.0.0.1 http://example.com`
|
|
215
|
+
// passes a URL-only check and then talks to loopback. Each one is judged by
|
|
216
|
+
// the address it actually points curl at.
|
|
217
|
+
const CURL_TARGET_FLAGS = new Set(['--resolve', '--connect-to', '--unix-socket', '--abstract-unix-socket', '-x', '--proxy', '--preproxy', '--socks4', '--socks4a', '--socks5', '--socks5-hostname']);
|
|
218
|
+
function curlTargetOverrides(args) {
|
|
219
|
+
const out = [];
|
|
220
|
+
for (let i = 0; i < args.length; i++) {
|
|
221
|
+
const arg = args[i];
|
|
222
|
+
const eq = arg.indexOf('=');
|
|
223
|
+
if (arg.startsWith('--') && eq !== -1 && CURL_TARGET_FLAGS.has(arg.slice(0, eq))) {
|
|
224
|
+
out.push({ flag: arg.slice(0, eq), value: arg.slice(eq + 1) });
|
|
225
|
+
}
|
|
226
|
+
else if (CURL_TARGET_FLAGS.has(arg)) {
|
|
227
|
+
out.push({ flag: arg, value: args[i + 1] ?? '' });
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
else if (/^-x./.test(arg)) {
|
|
231
|
+
out.push({ flag: '-x', value: arg.slice(2) });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
async function curlTargetOverrideProblem(args) {
|
|
237
|
+
for (const { flag, value } of curlTargetOverrides(args)) {
|
|
238
|
+
if (flag === '--unix-socket' || flag === '--abstract-unix-socket') {
|
|
239
|
+
return `${flag} is not allowed — it bypasses the network address check`;
|
|
240
|
+
}
|
|
241
|
+
if (flag === '--resolve') {
|
|
242
|
+
// [+]host:port:addr[,addr]… — every address must be public.
|
|
243
|
+
const addrs = value.replace(/^\+/, '').split(':').slice(2).join(':');
|
|
244
|
+
for (const addr of addrs.split(',')) {
|
|
245
|
+
if (!addr || isBlockedIp(addr))
|
|
246
|
+
return `--resolve points at a private/internal address (${addr || 'empty'})`;
|
|
247
|
+
if (!isIP(addr.replace(/^\[|\]$/g, '')))
|
|
248
|
+
return `--resolve needs a numeric address (got ${addr})`;
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
let target;
|
|
253
|
+
if (flag === '--connect-to') {
|
|
254
|
+
// HOST1:PORT1:HOST2:PORT2 — an empty HOST2 keeps the URL's host.
|
|
255
|
+
const m = value.match(/^(\[[^\]]*\]|[^:]*):[^:]*:(\[[^\]]*\]|[^:]*)(?::.*)?$/);
|
|
256
|
+
if (!m)
|
|
257
|
+
return `--connect-to value not understood: ${value}`;
|
|
258
|
+
if (!m[2])
|
|
259
|
+
continue;
|
|
260
|
+
target = `http://${m[2]}/`;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// Proxy flags: judge the proxy host. Any scheme is rewritten to http so
|
|
264
|
+
// the guard parses the host the same way for socks5:// and friends.
|
|
265
|
+
target = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value.replace(/^[a-z][a-z0-9+.-]*:/i, 'http:') : `http://${value}`;
|
|
266
|
+
}
|
|
267
|
+
const blocked = await assertFetchUrlAllowed(target);
|
|
268
|
+
if (blocked)
|
|
269
|
+
return `${flag} ${blocked}`;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
150
273
|
// Exec-escapes: whitelisted utilities that can run ARBITRARY other commands
|
|
151
274
|
// as part of their arguments, silently bypassing the whitelist above.
|
|
152
275
|
// find . -exec <anything> \; → runs <anything>
|
|
@@ -316,7 +439,12 @@ export async function validateCommandAsync(command, args, options) {
|
|
|
316
439
|
if (!sync.valid)
|
|
317
440
|
return sync;
|
|
318
441
|
if (URL_CARRYING_COMMANDS.has(command)) {
|
|
319
|
-
|
|
442
|
+
if (command === 'curl') {
|
|
443
|
+
const problem = await curlTargetOverrideProblem(args);
|
|
444
|
+
if (problem)
|
|
445
|
+
return { valid: false, reason: `Blocked curl option: ${problem}` };
|
|
446
|
+
}
|
|
447
|
+
for (const url of extractUrlCandidates(command, args)) {
|
|
320
448
|
const blocked = await assertFetchUrlAllowed(url);
|
|
321
449
|
if (blocked) {
|
|
322
450
|
return { valid: false, reason: `Blocked URL in ${command} arguments: ${blocked}` };
|
|
@@ -3,16 +3,39 @@
|
|
|
3
3
|
* touching surfaces.
|
|
4
4
|
*
|
|
5
5
|
* Used by:
|
|
6
|
-
* - toolExecution.ts → the `fetch_url` tool
|
|
6
|
+
* - toolExecution.ts → the `fetch_url` tool (every redirect hop, pinned)
|
|
7
7
|
* - shell.ts → curl/wget/http(s) arguments in execute_command
|
|
8
|
+
* - webFetch.ts → redirect hops of a user's `@web` mention
|
|
8
9
|
*
|
|
9
|
-
* The URLs in
|
|
10
|
+
* The URLs in the agent cases originate from model output / page content
|
|
10
11
|
* (untrusted, prompt-injectable), so the agent must not be able to reach
|
|
11
12
|
* internal services or the cloud metadata endpoint (169.254.169.254).
|
|
12
13
|
* NOTE: this deliberately does NOT apply to user-configured provider base
|
|
13
14
|
* URLs (Ollama localhost, custom vLLM/Tailscale endpoints) — those are
|
|
14
15
|
* trusted config and never routed through agent tools.
|
|
15
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* Whether an IP literal points somewhere the agent must not reach. Brackets
|
|
19
|
+
* are accepted (`[::1]`); anything that isn't an IP literal returns false —
|
|
20
|
+
* hostnames are resolved by `resolveFetchTarget`, not judged by spelling.
|
|
21
|
+
*/
|
|
16
22
|
export declare function isBlockedIp(ip: string): boolean;
|
|
23
|
+
export type FetchTargetCheck = {
|
|
24
|
+
ok: true;
|
|
25
|
+
url: URL;
|
|
26
|
+
/** Every checked address, to pin the connection to, so a second DNS
|
|
27
|
+
* answer can't swap in a private one (rebinding). All of them, not
|
|
28
|
+
* the first: a dual-stack host whose AAAA sorts first would otherwise
|
|
29
|
+
* be unreachable on a machine without a working IPv6 route. Undefined
|
|
30
|
+
* for IP literals — nothing to resolve — and when DNS failed. */
|
|
31
|
+
addresses?: string[];
|
|
32
|
+
/** True when the host was a name that did not resolve. */
|
|
33
|
+
unresolved?: boolean;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
/** Validate a URL and resolve its host, returning the verified address. */
|
|
39
|
+
export declare function resolveFetchTarget(rawUrl: string): Promise<FetchTargetCheck>;
|
|
17
40
|
/** Returns an error string if the URL must not be fetched, else null. */
|
|
18
41
|
export declare function assertFetchUrlAllowed(rawUrl: string): Promise<string | null>;
|
package/dist/utils/ssrfGuard.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* touching surfaces.
|
|
4
4
|
*
|
|
5
5
|
* Used by:
|
|
6
|
-
* - toolExecution.ts → the `fetch_url` tool
|
|
6
|
+
* - toolExecution.ts → the `fetch_url` tool (every redirect hop, pinned)
|
|
7
7
|
* - shell.ts → curl/wget/http(s) arguments in execute_command
|
|
8
|
+
* - webFetch.ts → redirect hops of a user's `@web` mention
|
|
8
9
|
*
|
|
9
|
-
* The URLs in
|
|
10
|
+
* The URLs in the agent cases originate from model output / page content
|
|
10
11
|
* (untrusted, prompt-injectable), so the agent must not be able to reach
|
|
11
12
|
* internal services or the cloud metadata endpoint (169.254.169.254).
|
|
12
13
|
* NOTE: this deliberately does NOT apply to user-configured provider base
|
|
@@ -14,70 +15,147 @@
|
|
|
14
15
|
* trusted config and never routed through agent tools.
|
|
15
16
|
*/
|
|
16
17
|
import { lookup as dnsLookup } from 'dns/promises';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
import { isIP } from 'net';
|
|
19
|
+
/** Strict dotted-quad → 4 octets, or null. */
|
|
20
|
+
function parseIPv4(s) {
|
|
21
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s))
|
|
22
|
+
return null;
|
|
23
|
+
const octets = s.split('.').map(Number);
|
|
24
|
+
return octets.every((n) => n <= 255) ? octets : null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* IPv6 literal → 16 bytes, or null. Handles `::` compression, a trailing
|
|
28
|
+
* dotted quad (`::ffff:1.2.3.4`) and a zone id (`fe80::1%en0`).
|
|
29
|
+
*
|
|
30
|
+
* Classifying bytes rather than the string is the point: the WHATWG URL
|
|
31
|
+
* parser rewrites `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, so a guard that
|
|
32
|
+
* pattern-matches the dotted spelling never sees the address it has to block.
|
|
33
|
+
*/
|
|
34
|
+
function parseIPv6(input) {
|
|
35
|
+
let s = input.toLowerCase();
|
|
36
|
+
const zone = s.indexOf('%');
|
|
37
|
+
if (zone !== -1)
|
|
38
|
+
s = s.slice(0, zone);
|
|
39
|
+
if (isIP(s) !== 6)
|
|
40
|
+
return null;
|
|
41
|
+
let tail = null;
|
|
42
|
+
const lastColon = s.lastIndexOf(':');
|
|
43
|
+
if (s.slice(lastColon + 1).includes('.')) {
|
|
44
|
+
tail = parseIPv4(s.slice(lastColon + 1));
|
|
45
|
+
if (!tail)
|
|
46
|
+
return null;
|
|
47
|
+
s = s.slice(0, lastColon + 1) + '0:0'; // two placeholder groups
|
|
48
|
+
}
|
|
49
|
+
let groups;
|
|
50
|
+
if (s.includes('::')) {
|
|
51
|
+
const [left, right] = s.split('::');
|
|
52
|
+
const l = left ? left.split(':') : [];
|
|
53
|
+
const r = right ? right.split(':') : [];
|
|
54
|
+
groups = [...l, ...Array(8 - l.length - r.length).fill('0'), ...r];
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
groups = s.split(':');
|
|
58
|
+
}
|
|
59
|
+
if (groups.length !== 8)
|
|
60
|
+
return null;
|
|
61
|
+
const bytes = groups.flatMap((g) => {
|
|
62
|
+
const v = parseInt(g, 16);
|
|
63
|
+
return [v >> 8, v & 0xff];
|
|
64
|
+
});
|
|
65
|
+
if (tail)
|
|
66
|
+
bytes.splice(12, 4, ...tail);
|
|
67
|
+
return bytes;
|
|
68
|
+
}
|
|
69
|
+
function isBlockedIPv4([a, b, c]) {
|
|
70
|
+
return a === 0 // 0.0.0.0/8 "this network"
|
|
71
|
+
|| a === 10 // RFC1918
|
|
72
|
+
|| a === 127 // loopback
|
|
73
|
+
|| (a === 100 && b >= 64 && b <= 127) // 100.64.0.0/10 CGNAT — Tailscale lives here
|
|
74
|
+
|| (a === 169 && b === 254) // link-local incl. metadata 169.254.169.254
|
|
75
|
+
|| (a === 172 && b >= 16 && b <= 31) // RFC1918
|
|
76
|
+
|| (a === 192 && b === 0 && c === 0) // 192.0.0.0/24 IETF protocol assignments
|
|
77
|
+
|| (a === 192 && b === 168) // RFC1918
|
|
78
|
+
|| (a === 198 && (b === 18 || b === 19)) // 198.18.0.0/15 benchmarking
|
|
79
|
+
|| a >= 224; // multicast, reserved, broadcast
|
|
80
|
+
}
|
|
81
|
+
function isBlockedIPv6(b) {
|
|
82
|
+
const zeroUpTo = (n) => b.slice(0, n).every((x) => x === 0);
|
|
83
|
+
// Forms that carry an IPv4 address — judge the embedded address.
|
|
84
|
+
if (zeroUpTo(10) && b[10] === 0xff && b[11] === 0xff)
|
|
85
|
+
return isBlockedIPv4(b.slice(12)); // ::ffff:0:0/96 mapped
|
|
86
|
+
if (zeroUpTo(12))
|
|
87
|
+
return isBlockedIPv4(b.slice(12)); // ::/96 compatible — also covers :: and ::1
|
|
88
|
+
if (b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b) {
|
|
89
|
+
if (b.slice(4, 12).every((x) => x === 0))
|
|
90
|
+
return isBlockedIPv4(b.slice(12)); // 64:ff9b::/96 NAT64
|
|
91
|
+
if (b[4] === 0x00 && b[5] === 0x01)
|
|
92
|
+
return true; // 64:ff9b:1::/48 local-use NAT64
|
|
29
93
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
return true; //
|
|
36
|
-
if (
|
|
37
|
-
return true; //
|
|
38
|
-
if (
|
|
39
|
-
return true; //
|
|
40
|
-
if (a === 192 && b === 168)
|
|
41
|
-
return true; // RFC1918
|
|
42
|
-
if (a === 169 && b === 254)
|
|
43
|
-
return true; // link-local incl. metadata 169.254.169.254
|
|
44
|
-
if (a === 0)
|
|
45
|
-
return true; // 0.0.0.0/8
|
|
94
|
+
if (b[0] === 0x20 && b[1] === 0x02)
|
|
95
|
+
return isBlockedIPv4(b.slice(2, 6)); // 2002::/16 6to4
|
|
96
|
+
if ((b[0] & 0xfe) === 0xfc)
|
|
97
|
+
return true; // fc00::/7 unique local
|
|
98
|
+
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80)
|
|
99
|
+
return true; // fe80::/10 link-local
|
|
100
|
+
if (b[0] === 0xfe && (b[1] & 0xc0) === 0xc0)
|
|
101
|
+
return true; // fec0::/10 site-local (deprecated)
|
|
102
|
+
if (b[0] === 0xff)
|
|
103
|
+
return true; // ff00::/8 multicast
|
|
46
104
|
return false;
|
|
47
105
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Whether an IP literal points somewhere the agent must not reach. Brackets
|
|
108
|
+
* are accepted (`[::1]`); anything that isn't an IP literal returns false —
|
|
109
|
+
* hostnames are resolved by `resolveFetchTarget`, not judged by spelling.
|
|
110
|
+
*/
|
|
111
|
+
export function isBlockedIp(ip) {
|
|
112
|
+
const s = ip.trim().replace(/^\[|\]$/g, '');
|
|
113
|
+
const v4 = parseIPv4(s);
|
|
114
|
+
if (v4)
|
|
115
|
+
return isBlockedIPv4(v4);
|
|
116
|
+
const v6 = parseIPv6(s);
|
|
117
|
+
return v6 ? isBlockedIPv6(v6) : false;
|
|
118
|
+
}
|
|
119
|
+
/** Validate a URL and resolve its host, returning the verified address. */
|
|
120
|
+
export async function resolveFetchTarget(rawUrl) {
|
|
50
121
|
let u;
|
|
51
122
|
try {
|
|
52
123
|
u = new URL(rawUrl);
|
|
53
124
|
}
|
|
54
125
|
catch {
|
|
55
|
-
return 'Invalid URL format';
|
|
126
|
+
return { ok: false, reason: 'Invalid URL format' };
|
|
56
127
|
}
|
|
57
128
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
58
|
-
return `Blocked: only http/https URLs can be fetched (got "${u.protocol}")
|
|
129
|
+
return { ok: false, reason: `Blocked: only http/https URLs can be fetched (got "${u.protocol}")` };
|
|
59
130
|
}
|
|
60
131
|
const host = u.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
|
|
61
132
|
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
62
|
-
return 'Blocked: localhost is not fetchable by the agent';
|
|
133
|
+
return { ok: false, reason: 'Blocked: localhost is not fetchable by the agent' };
|
|
63
134
|
}
|
|
64
|
-
if (
|
|
65
|
-
// Literal IP — check directly.
|
|
135
|
+
if (isIP(host)) {
|
|
66
136
|
if (isBlockedIp(host))
|
|
67
|
-
return `Blocked: ${host} is a private/loopback/link-local address
|
|
68
|
-
return
|
|
137
|
+
return { ok: false, reason: `Blocked: ${host} is a private/loopback/link-local address` };
|
|
138
|
+
return { ok: true, url: u };
|
|
69
139
|
}
|
|
70
140
|
// Resolve and check every address (catches internal hostnames + single-record rebinding).
|
|
141
|
+
let addrs;
|
|
71
142
|
try {
|
|
72
|
-
|
|
73
|
-
for (const a of addrs) {
|
|
74
|
-
if (isBlockedIp(a.address)) {
|
|
75
|
-
return `Blocked: ${host} resolves to a private/internal address (${a.address})`;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
143
|
+
addrs = await dnsLookup(host, { all: true });
|
|
78
144
|
}
|
|
79
145
|
catch {
|
|
80
|
-
// DNS failure —
|
|
146
|
+
// DNS failure — not an SSRF risk by itself; callers decide whether an
|
|
147
|
+
// unpinned request is acceptable.
|
|
148
|
+
return { ok: true, url: u, unresolved: true };
|
|
149
|
+
}
|
|
150
|
+
for (const a of addrs) {
|
|
151
|
+
if (isBlockedIp(a.address)) {
|
|
152
|
+
return { ok: false, reason: `Blocked: ${host} resolves to a private/internal address (${a.address})` };
|
|
153
|
+
}
|
|
81
154
|
}
|
|
82
|
-
return
|
|
155
|
+
return { ok: true, url: u, addresses: addrs.map((a) => a.address) };
|
|
156
|
+
}
|
|
157
|
+
/** Returns an error string if the URL must not be fetched, else null. */
|
|
158
|
+
export async function assertFetchUrlAllowed(rawUrl) {
|
|
159
|
+
const check = await resolveFetchTarget(rawUrl);
|
|
160
|
+
return check.ok ? null : check.reason;
|
|
83
161
|
}
|
|
@@ -21,7 +21,7 @@ import { isMcpToolName, callSessionTool, isVirtualMcpToolName, callSessionVirtua
|
|
|
21
21
|
// shared with shell.ts for curl/wget URL checks. Re-exported here so the
|
|
22
22
|
// existing tests that import it from toolExecution keep working.
|
|
23
23
|
export { isBlockedIp, assertFetchUrlAllowed } from './ssrfGuard.js';
|
|
24
|
-
import {
|
|
24
|
+
import { fetchUrlGuarded } from './guardedFetch.js';
|
|
25
25
|
const debug = (...args) => {
|
|
26
26
|
if (process.env.CODEEP_DEBUG === '1') {
|
|
27
27
|
logger.debug(args.map(String).join(' '));
|
|
@@ -554,19 +554,14 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
554
554
|
const url = parameters.url;
|
|
555
555
|
if (!url)
|
|
556
556
|
return { success: false, output: '', error: 'Missing required parameter: url', tool, parameters };
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
return { success: false, output: '', error: blockedReason, tool, parameters };
|
|
560
|
-
// Restrict to http/https on the initial request AND redirects, and cap
|
|
561
|
-
// redirect hops — defends against protocol-smuggling and limits
|
|
562
|
-
// redirect-based SSRF reach (initial host is already IP-checked above).
|
|
563
|
-
const result = await executeCommandAsync('curl', ['-s', '-L', '--proto', '=http,https', '--proto-redir', '=http,https', '--max-redirs', '5', '-m', '30', '-A', 'Codeep/1.0', '--max-filesize', '1000000', url], {
|
|
557
|
+
// Every redirect hop is SSRF-checked and pinned — see guardedFetch.ts.
|
|
558
|
+
const result = await fetchUrlGuarded(url, (args) => executeCommandAsync('curl', args, {
|
|
564
559
|
cwd: projectRoot,
|
|
565
560
|
projectRoot,
|
|
566
561
|
timeout: 35000,
|
|
567
|
-
});
|
|
568
|
-
if (result.
|
|
569
|
-
let content = result.
|
|
562
|
+
}));
|
|
563
|
+
if (result.ok) {
|
|
564
|
+
let content = result.body;
|
|
570
565
|
if (content.includes('<html') || content.includes('<!DOCTYPE')) {
|
|
571
566
|
content = htmlToText(content);
|
|
572
567
|
}
|
|
@@ -574,7 +569,7 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
574
569
|
content = content.substring(0, 10000) + '\n\n... (truncated)';
|
|
575
570
|
return { success: true, output: content, tool, parameters };
|
|
576
571
|
}
|
|
577
|
-
return { success: false, output: '', error: result.
|
|
572
|
+
return { success: false, output: '', error: result.error, tool, parameters };
|
|
578
573
|
}
|
|
579
574
|
// === Z.AI MCP Tools ===
|
|
580
575
|
case 'web_search': {
|
package/dist/utils/webFetch.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* The fetcher is async, so `expandWebMentions` is async — unlike the
|
|
21
21
|
* sync `expandMentions` for files. Callers await it.
|
|
22
22
|
*/
|
|
23
|
+
import { assertFetchUrlAllowed, isBlockedIp } from './ssrfGuard.js';
|
|
23
24
|
/** Max bytes of text we'll inline from a fetched page (32 KB). */
|
|
24
25
|
export const MAX_WEB_BYTES = 32 * 1024;
|
|
25
26
|
/** Fetch timeout — don't hang the chat on a slow server. */
|
|
@@ -175,24 +176,16 @@ export function webCacheStats() {
|
|
|
175
176
|
* endpoint), and `.internal`-style names.
|
|
176
177
|
*
|
|
177
178
|
* A user typing `@web http://localhost:3000` is a documented, intended use, so
|
|
178
|
-
* this is NOT a blanket block — it
|
|
179
|
-
*
|
|
179
|
+
* this is NOT a blanket block — it only decides whether redirect hops get
|
|
180
|
+
* checked, so a public URL can't bounce us into the private network.
|
|
180
181
|
*/
|
|
181
182
|
function isPrivateHost(hostname) {
|
|
182
183
|
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
183
184
|
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.internal') || h.endsWith('.local'))
|
|
184
185
|
return true;
|
|
185
|
-
|
|
186
|
-
return true;
|
|
187
|
-
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
188
|
-
if (!m)
|
|
189
|
-
return false;
|
|
190
|
-
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
191
|
-
return a === 127 || a === 10 || a === 0
|
|
192
|
-
|| (a === 192 && b === 168)
|
|
193
|
-
|| (a === 172 && b >= 16 && b <= 31)
|
|
194
|
-
|| (a === 169 && b === 254);
|
|
186
|
+
return isBlockedIp(h);
|
|
195
187
|
}
|
|
188
|
+
const MAX_REDIRECTS = 5;
|
|
196
189
|
/** Hard ceiling on bytes read from the network, before any text decoding. */
|
|
197
190
|
const MAX_WEB_FETCH_BYTES = MAX_WEB_BYTES * 4;
|
|
198
191
|
async function safeFetch(url, fetchImpl) {
|
|
@@ -202,6 +195,8 @@ async function safeFetch(url, fetchImpl) {
|
|
|
202
195
|
// `finally` so a throw can't leak the timer either.
|
|
203
196
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
204
197
|
try {
|
|
198
|
+
// Judged like the hops (names resolved, not just spelled), so a LAN host
|
|
199
|
+
// such as `nas.home` may still redirect within the LAN it lives on.
|
|
205
200
|
const requestedPrivate = (() => {
|
|
206
201
|
try {
|
|
207
202
|
return isPrivateHost(new URL(url).hostname);
|
|
@@ -209,24 +204,40 @@ async function safeFetch(url, fetchImpl) {
|
|
|
209
204
|
catch {
|
|
210
205
|
return false;
|
|
211
206
|
}
|
|
212
|
-
})();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
207
|
+
})() || (await assertFetchUrlAllowed(url)) !== null;
|
|
208
|
+
// Redirects are followed here, not by fetch: the user vouched for the host
|
|
209
|
+
// they typed, not for wherever it redirects. With `redirect: 'follow'`
|
|
210
|
+
// only the final URL could be checked, after every hop — private ones
|
|
211
|
+
// included — had already been requested.
|
|
212
|
+
let current = url;
|
|
213
|
+
let res;
|
|
214
|
+
for (let hop = 0;; hop++) {
|
|
215
|
+
if (hop > 0 && !requestedPrivate && await assertFetchUrlAllowed(current)) {
|
|
216
|
+
return { ok: false, reason: 'redirected to a private/internal address — refused' };
|
|
217
|
+
}
|
|
218
|
+
res = await fetchImpl(current, {
|
|
219
|
+
signal: controller.signal,
|
|
220
|
+
headers: { 'User-Agent': USER_AGENT, Accept: 'text/html, text/plain, */*' },
|
|
221
|
+
redirect: 'manual',
|
|
222
|
+
});
|
|
223
|
+
const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
|
224
|
+
if (!location)
|
|
225
|
+
break;
|
|
226
|
+
if (hop >= MAX_REDIRECTS)
|
|
227
|
+
return { ok: false, reason: `too many redirects (more than ${MAX_REDIRECTS})` };
|
|
224
228
|
try {
|
|
225
|
-
|
|
226
|
-
return { ok: false, reason: 'redirected to a private/internal address — refused' };
|
|
227
|
-
}
|
|
229
|
+
await res.body?.cancel();
|
|
228
230
|
}
|
|
229
|
-
catch { /*
|
|
231
|
+
catch { /* nothing to drain */ }
|
|
232
|
+
try {
|
|
233
|
+
current = new URL(location, current).href;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return { ok: false, reason: `HTTP ${res.status} with an invalid redirect` };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
return { ok: false, reason: `HTTP ${res.status}` };
|
|
230
241
|
}
|
|
231
242
|
const declared = Number(res.headers.get('content-length') ?? '');
|
|
232
243
|
if (Number.isFinite(declared) && declared > MAX_WEB_FETCH_BYTES) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "3.3.
|
|
1
|
+
export declare const VERSION = "3.3.3";
|
package/dist/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
|
|
2
2
|
// Baked from package.json at build time so the bun-compiled binary reports
|
|
3
3
|
// the right version (it has no package.json on disk to read at runtime).
|
|
4
|
-
export const VERSION = '3.3.
|
|
4
|
+
export const VERSION = '3.3.3';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@napi-rs/keyring": "^1.3.0",
|
|
44
44
|
"clipboardy": "^4.0.0",
|
|
45
45
|
"conf": "^13.1.0",
|
|
46
|
-
"js-yaml": "^4.3.
|
|
46
|
+
"js-yaml": "^4.3.2",
|
|
47
47
|
"open": "^10.0.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|