@poolzin/pool-bot 2026.3.17 → 2026.3.18
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/CHANGELOG.md +56 -0
- package/dist/agents/tools/web-fetch.js +1 -1
- package/dist/build-info.json +2 -2
- package/dist/commands/skills-openclaw.command.js +123 -0
- package/dist/config/paths.js +7 -0
- package/dist/infra/net/fetch-guard.js +191 -146
- package/dist/media/fetch.js +83 -112
- package/dist/media/inbound-path-policy.js +90 -97
- package/dist/media/read-response-with-limit.js +49 -26
- package/dist/media-understanding/attachments.js +1 -1
- package/dist/plugin-sdk/audio.js +7 -0
- package/dist/plugin-sdk/bluebubbles.js +7 -0
- package/dist/plugin-sdk/browser.js +7 -0
- package/dist/plugin-sdk/canvas.js +7 -0
- package/dist/plugin-sdk/cron.js +7 -0
- package/dist/plugin-sdk/discord-actions.js +6 -0
- package/dist/plugin-sdk/discord.js +7 -0
- package/dist/plugin-sdk/image.js +7 -0
- package/dist/plugin-sdk/imessage.js +6 -0
- package/dist/plugin-sdk/keyed-async-queue.js +35 -0
- package/dist/plugin-sdk/media.js +8 -0
- package/dist/plugin-sdk/memory.js +7 -0
- package/dist/plugin-sdk/pdf.js +7 -0
- package/dist/plugin-sdk/sessions.js +7 -0
- package/dist/plugin-sdk/signal.js +6 -0
- package/dist/plugin-sdk/slack-actions.js +7 -0
- package/dist/plugin-sdk/slack.js +7 -0
- package/dist/plugin-sdk/telegram-actions.js +6 -0
- package/dist/plugin-sdk/telegram.js +6 -0
- package/dist/plugin-sdk/test-utils.js +110 -0
- package/dist/plugin-sdk/tts.js +7 -0
- package/dist/plugin-sdk/whatsapp.js +6 -0
- package/dist/providers/github-copilot-auth.js +53 -76
- package/dist/providers/github-copilot-models.js +63 -35
- package/dist/providers/github-copilot-token.js +46 -89
- package/dist/security/audit-findings.js +165 -0
- package/dist/security/audit.js +141 -572
- package/dist/skills/openclaw-skill-loader.js +191 -0
- package/dist/slack/monitor/media.js +2 -1
- package/docs/improvements/OPENCLAW-IMPLEMENTATION.md +45 -0
- package/docs/skills/openclaw-integration.md +295 -0
- package/docs/testing/TEST-PLAN-2026-03-13.md +338 -0
- package/extensions/acpx/package.json +19 -0
- package/extensions/acpx/poolbot.plugin.json +9 -0
- package/extensions/acpx/src/index.ts +34 -0
- package/extensions/bluebubbles/src/runtime.ts +1 -0
- package/extensions/diffs/package.json +15 -0
- package/extensions/diffs/poolbot.plugin.json +10 -0
- package/extensions/diffs/src/index.ts +106 -0
- package/extensions/discord/src/runtime.ts +1 -0
- package/extensions/feishu/src/runtime.ts +1 -0
- package/extensions/github-copilot/package.json +28 -0
- package/extensions/github-copilot/poolbot.plugin.json +29 -0
- package/extensions/github-copilot/src/index.ts +126 -0
- package/extensions/github-copilot/tsconfig.json +10 -0
- package/extensions/googlechat/src/runtime.ts +1 -0
- package/extensions/imessage/src/runtime.ts +1 -0
- package/extensions/irc/src/runtime.ts +1 -0
- package/extensions/line/src/runtime.ts +1 -0
- package/extensions/matrix/src/runtime.ts +1 -0
- package/extensions/mattermost/src/mattermost/monitor-helpers.ts +10 -1
- package/extensions/mattermost/src/runtime.ts +6 -3
- package/extensions/msteams/src/runtime.ts +1 -0
- package/extensions/nextcloud-talk/src/runtime.ts +1 -0
- package/extensions/nostr/src/runtime.ts +5 -2
- package/extensions/ollama/package.json +20 -0
- package/extensions/ollama/poolbot.plugin.json +14 -0
- package/extensions/ollama/src/index.ts +95 -0
- package/extensions/sglang/package.json +18 -0
- package/extensions/sglang/poolbot.plugin.json +13 -0
- package/extensions/sglang/src/index.ts +62 -0
- package/extensions/signal/src/runtime.ts +1 -0
- package/extensions/slack/src/runtime.ts +1 -0
- package/extensions/telegram/src/runtime.ts +1 -0
- package/extensions/test-utils/package.json +17 -0
- package/extensions/test-utils/poolbot.plugin.json +16 -0
- package/extensions/test-utils/src/index.ts +220 -0
- package/extensions/tlon/src/runtime.ts +1 -0
- package/extensions/twitch/src/runtime.ts +1 -0
- package/extensions/vllm/package.json +19 -0
- package/extensions/vllm/poolbot.plugin.json +13 -0
- package/extensions/vllm/src/index.ts +90 -0
- package/extensions/whatsapp/src/runtime.ts +1 -0
- package/extensions/zalo/src/runtime.ts +1 -0
- package/extensions/zalouser/src/runtime.ts +1 -0
- package/package.json +77 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
## v2026.3.18 (2026-03-13)
|
|
2
|
+
|
|
3
|
+
### 🚀 OpenClaw Master Skills Integration
|
|
4
|
+
- **339 OpenClaw skills** integrated with CLI support
|
|
5
|
+
- **13 categories** organized (AI & LLM, Development, Productivity, etc.)
|
|
6
|
+
- **Skill loader** programático com validação
|
|
7
|
+
- **CLI commands:** `poolbot skills-openclaw list|search|show|install-deps`
|
|
8
|
+
|
|
9
|
+
### 🔐 Security Audit Framework (v1.0)
|
|
10
|
+
- **8 security checks** implementados (CFG-001, GTW-001, CHN-001/002/003, MDL-001, FS-001/002)
|
|
11
|
+
- **Deep gateway probe** com timeout configurável
|
|
12
|
+
- **25+ integration tests** para validação de segurança
|
|
13
|
+
- **Cross-platform:** Linux, macOS, Windows
|
|
14
|
+
|
|
15
|
+
### 🔌 Plugin SDK Type Exports
|
|
16
|
+
- **19 specific exports** adicionados ao package.json
|
|
17
|
+
- Melhor tree-shaking e DX para desenvolvedores de plugins
|
|
18
|
+
|
|
19
|
+
### 🛡️ Media Pipeline Security
|
|
20
|
+
- **SSRF protection** em fetch operations
|
|
21
|
+
- **Path traversal prevention** com inbound-path-policy
|
|
22
|
+
- **Read idle timeout** para prevenir hanging connections
|
|
23
|
+
- **Max bytes limits** para proteção contra memory exhaustion
|
|
24
|
+
|
|
25
|
+
### 🤖 GitHub Copilot Provider
|
|
26
|
+
- **OAuth Device Flow** implementado
|
|
27
|
+
- **5 models** suportados (GPT-4o, GPT-4o Mini, GPT-4 Turbo, Claude 3.5 Sonnet, Claude 3 Opus)
|
|
28
|
+
- CLI commands para auth e model management
|
|
29
|
+
|
|
30
|
+
### 📦 New Extensions
|
|
31
|
+
- **acpx:** Agent capability extensions
|
|
32
|
+
- **diffs:** Diff analysis tools
|
|
33
|
+
- **ollama:** Local LLM integration
|
|
34
|
+
- **sglang:** Structured generation
|
|
35
|
+
- **vllm:** High-throughput inference
|
|
36
|
+
- **test-utils:** Testing utilities
|
|
37
|
+
- **github-copilot:** GitHub Copilot provider
|
|
38
|
+
|
|
39
|
+
### 🎨 UI Components (Library)
|
|
40
|
+
- **Export conversations:** Markdown, JSON, HTML
|
|
41
|
+
- **Pinned messages:** Gerenciamento de mensagens importantes
|
|
42
|
+
- **Search:** Fuzzy matching com highlighting
|
|
43
|
+
- **Slash commands:** Sistema de comandos com registry
|
|
44
|
+
|
|
45
|
+
### ⚠️ Known Limitations
|
|
46
|
+
- UI components são library functions (UI real em desenvolvimento)
|
|
47
|
+
- Skills validation manual pendente para produção
|
|
48
|
+
- Test coverage em expansão (25+ tests adicionados)
|
|
49
|
+
|
|
50
|
+
### Documentation
|
|
51
|
+
- Complete test plan: `docs/testing/TEST-PLAN-2026-03-13.md`
|
|
52
|
+
- OpenClaw integration guide: `docs/skills/openclaw-integration.md`
|
|
53
|
+
- Security audit documentation
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
1
57
|
## v2026.3.17 (2026-03-12)
|
|
2
58
|
|
|
3
59
|
### Features
|
|
@@ -362,7 +362,7 @@ async function runWebFetch(params) {
|
|
|
362
362
|
},
|
|
363
363
|
});
|
|
364
364
|
res = result.response;
|
|
365
|
-
finalUrl = result.finalUrl;
|
|
365
|
+
finalUrl = result.finalUrl ?? params.url;
|
|
366
366
|
release = result.release;
|
|
367
367
|
// Cloudflare Markdown for Agents — log token budget hint when present
|
|
368
368
|
const markdownTokens = res.headers.get("x-markdown-tokens");
|
package/dist/build-info.json
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Skills CLI Commands
|
|
3
|
+
*/
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { loadOpenClawSkills, getSkillByName, searchSkills, getSkillsByCategory, installSkillDependencies, } from "../skills/openclaw-skill-loader.js";
|
|
6
|
+
import { resolveSkillsDir } from "../config/paths.js";
|
|
7
|
+
export function createOpenClawSkillsCommand() {
|
|
8
|
+
const cmd = new Command("skills-openclaw");
|
|
9
|
+
cmd
|
|
10
|
+
.description("Manage OpenClaw Master Skills (339+ skills)")
|
|
11
|
+
.addCommand(new Command("list")
|
|
12
|
+
.description("List all available skills")
|
|
13
|
+
.option("-c, --category <category>", "Filter by category")
|
|
14
|
+
.option("-f, --format <format>", "Output format (table, json)", "table")
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
const skillsDir = resolveSkillsDir("openclaw");
|
|
17
|
+
const { skills, categories } = await loadOpenClawSkills(skillsDir);
|
|
18
|
+
if (options.category) {
|
|
19
|
+
const filtered = await getSkillsByCategory(skillsDir, options.category);
|
|
20
|
+
console.log(`\n📦 ${options.category.toUpperCase()} Skills (${filtered.length})\n`);
|
|
21
|
+
for (const skill of filtered.slice(0, 20)) {
|
|
22
|
+
console.log(` ${skill.name.padEnd(30)} - ${skill.description.slice(0, 60)}`);
|
|
23
|
+
}
|
|
24
|
+
if (filtered.length > 20) {
|
|
25
|
+
console.log(` ... and ${filtered.length - 20} more`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.log(`\n📚 OpenClaw Master Skills (${skills.length} total)\n`);
|
|
30
|
+
for (const category of categories) {
|
|
31
|
+
console.log(`${category.emoji} ${category.name} (${category.skills.length})`);
|
|
32
|
+
}
|
|
33
|
+
console.log("\nUse --category to filter by category");
|
|
34
|
+
}
|
|
35
|
+
}))
|
|
36
|
+
.addCommand(new Command("search")
|
|
37
|
+
.description("Search skills by name or description")
|
|
38
|
+
.argument("<query>", "Search query")
|
|
39
|
+
.action(async (query) => {
|
|
40
|
+
const skillsDir = resolveSkillsDir("openclaw");
|
|
41
|
+
const results = await searchSkills(skillsDir, query);
|
|
42
|
+
console.log(`\n🔍 Search results for "${query}" (${results.length} found)\n`);
|
|
43
|
+
for (const skill of results.slice(0, 10)) {
|
|
44
|
+
console.log(` ${skill.name.padEnd(30)} - ${skill.description.slice(0, 60)}`);
|
|
45
|
+
}
|
|
46
|
+
if (results.length > 10) {
|
|
47
|
+
console.log(` ... and ${results.length - 10} more`);
|
|
48
|
+
}
|
|
49
|
+
}))
|
|
50
|
+
.addCommand(new Command("show")
|
|
51
|
+
.description("Show skill details")
|
|
52
|
+
.argument("<name>", "Skill name")
|
|
53
|
+
.action(async (name) => {
|
|
54
|
+
const skillsDir = resolveSkillsDir("openclaw");
|
|
55
|
+
const skill = await getSkillByName(skillsDir, name);
|
|
56
|
+
if (!skill) {
|
|
57
|
+
console.error(`❌ Skill "${name}" not found`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
console.log(`\n📦 ${skill.name}`);
|
|
61
|
+
console.log(`\n${skill.description}`);
|
|
62
|
+
if (skill.homepage) {
|
|
63
|
+
console.log(`\n🔗 Homepage: ${skill.homepage}`);
|
|
64
|
+
}
|
|
65
|
+
if (skill.requires?.bins) {
|
|
66
|
+
console.log(`\n🔧 Requires: ${skill.requires.bins.join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
if (skill.install) {
|
|
69
|
+
console.log("\n📥 Installation:");
|
|
70
|
+
for (const inst of skill.install) {
|
|
71
|
+
console.log(` ${inst.label}`);
|
|
72
|
+
if (inst.kind === "brew" && inst.formula) {
|
|
73
|
+
console.log(` brew install ${inst.formula}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
console.log();
|
|
78
|
+
}))
|
|
79
|
+
.addCommand(new Command("install")
|
|
80
|
+
.description("Install skill dependencies")
|
|
81
|
+
.argument("<name>", "Skill name")
|
|
82
|
+
.option("--dry-run", "Show commands without executing")
|
|
83
|
+
.action(async (name, options) => {
|
|
84
|
+
const skillsDir = resolveSkillsDir("openclaw");
|
|
85
|
+
const skill = await getSkillByName(skillsDir, name);
|
|
86
|
+
if (!skill) {
|
|
87
|
+
console.error(`❌ Skill "${name}" not found`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const { commands } = await installSkillDependencies(skill);
|
|
91
|
+
if (commands.length === 0) {
|
|
92
|
+
console.log(`✅ Skill "${name}" has no external dependencies`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
console.log(`\n📥 Installing dependencies for "${skill.name}"\n`);
|
|
96
|
+
for (const cmd of commands) {
|
|
97
|
+
if (options.dryRun) {
|
|
98
|
+
console.log(` [DRY] ${cmd}`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(` ${cmd}`);
|
|
102
|
+
// In production, would execute: await exec(cmd)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log();
|
|
106
|
+
}))
|
|
107
|
+
.addCommand(new Command("stats")
|
|
108
|
+
.description("Show skills statistics")
|
|
109
|
+
.action(async () => {
|
|
110
|
+
const skillsDir = resolveSkillsDir("openclaw");
|
|
111
|
+
const { skills, categories } = await loadOpenClawSkills(skillsDir);
|
|
112
|
+
console.log("\n📊 OpenClaw Master Skills Statistics\n");
|
|
113
|
+
console.log(`Total Skills: ${skills.length}`);
|
|
114
|
+
console.log(`Categories: ${categories.length}\n`);
|
|
115
|
+
console.log("By Category:");
|
|
116
|
+
for (const cat of categories.sort((a, b) => b.skills.length - a.skills.length)) {
|
|
117
|
+
const bar = "█".repeat(Math.round(cat.skills.length / 5));
|
|
118
|
+
console.log(` ${cat.emoji} ${cat.name.padEnd(20)} ${cat.skills.length.toString().padStart(3)} ${bar}`);
|
|
119
|
+
}
|
|
120
|
+
console.log();
|
|
121
|
+
}));
|
|
122
|
+
return cmd;
|
|
123
|
+
}
|
package/dist/config/paths.js
CHANGED
|
@@ -216,6 +216,13 @@ export function resolveOAuthDir(env = process.env, stateDir = resolveStateDir(en
|
|
|
216
216
|
export function resolveOAuthPath(env = process.env, stateDir = resolveStateDir(env, envHomedir(env))) {
|
|
217
217
|
return path.join(resolveOAuthDir(env, stateDir), OAUTH_FILENAME);
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Resolve skills directory for a specific skill type
|
|
221
|
+
*/
|
|
222
|
+
export function resolveSkillsDir(skillType, env = process.env) {
|
|
223
|
+
const stateDir = resolveStateDir(env, envHomedir(env));
|
|
224
|
+
return path.join(stateDir, "skills", skillType);
|
|
225
|
+
}
|
|
219
226
|
export function resolveGatewayPort(cfg, env = process.env) {
|
|
220
227
|
const envRaw = (env.POOLBOT_GATEWAY_PORT ?? env.CLAWDBOT_GATEWAY_PORT)?.trim();
|
|
221
228
|
if (envRaw) {
|
|
@@ -1,172 +1,217 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Fetch Guard with SSRF Protection
|
|
3
|
+
*
|
|
4
|
+
* Secure fetch implementation that prevents SSRF attacks
|
|
5
|
+
*/
|
|
6
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
7
|
+
import { isIP } from "node:net";
|
|
8
|
+
import ipaddr from "ipaddr.js";
|
|
9
|
+
import { isBlockedHostnameOrIp } from "./ssrf.js";
|
|
10
|
+
export class FetchGuardError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "FetchGuardError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_ALLOWED_PROTOCOLS = ["https:", "http:"];
|
|
19
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
21
|
+
/** Guarded fetch modes for proxy handling */
|
|
6
22
|
export const GUARDED_FETCH_MODE = {
|
|
7
23
|
STRICT: "strict",
|
|
8
24
|
TRUSTED_ENV_PROXY: "trusted_env_proxy",
|
|
9
25
|
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
function isRedirectStatus(status) {
|
|
33
|
-
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
34
|
-
}
|
|
35
|
-
function stripSensitiveHeadersForCrossOriginRedirect(init) {
|
|
36
|
-
if (!init?.headers) {
|
|
37
|
-
return init;
|
|
26
|
+
/**
|
|
27
|
+
* Check if an IP address is private/internal
|
|
28
|
+
*/
|
|
29
|
+
function isPrivateIP(ip) {
|
|
30
|
+
try {
|
|
31
|
+
let addr;
|
|
32
|
+
if (ipaddr.isValid(ip)) {
|
|
33
|
+
addr = ipaddr.parse(ip);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// Check for private ranges
|
|
39
|
+
if (addr.kind() === "ipv4") {
|
|
40
|
+
const ipv4 = addr;
|
|
41
|
+
return ipv4.range() !== "unicast";
|
|
42
|
+
}
|
|
43
|
+
else if (addr.kind() === "ipv6") {
|
|
44
|
+
const ipv6 = addr;
|
|
45
|
+
return ipv6.range() !== "unicast";
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
38
48
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
headers.delete(header);
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
42
51
|
}
|
|
43
|
-
return { ...init, headers };
|
|
44
52
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Validate a URL for SSRF protection
|
|
55
|
+
*/
|
|
56
|
+
function validateURL(url, options) {
|
|
57
|
+
// Check protocol
|
|
58
|
+
const allowedProtocols = options.allowedProtocols ?? DEFAULT_ALLOWED_PROTOCOLS;
|
|
59
|
+
if (!allowedProtocols.includes(url.protocol)) {
|
|
60
|
+
throw new FetchGuardError("invalid_protocol", `Protocol ${url.protocol} not allowed. Allowed: ${allowedProtocols.join(", ")}`);
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
// Check IP address
|
|
63
|
+
const hostname = url.hostname;
|
|
64
|
+
// Skip validation for non-IP hostnames
|
|
65
|
+
if (!isIP(hostname)) {
|
|
66
|
+
return;
|
|
52
67
|
}
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (signal.aborted) {
|
|
58
|
-
controller.abort();
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
62
|
-
}
|
|
68
|
+
// Check if private IP is allowed
|
|
69
|
+
const allowPrivateIPs = options.allowPrivateIPs ?? false;
|
|
70
|
+
if (!allowPrivateIPs && isPrivateIP(hostname)) {
|
|
71
|
+
throw new FetchGuardError("ssrf_blocked", `Access to private IP ${hostname} is blocked`);
|
|
63
72
|
}
|
|
64
|
-
const cleanup = () => {
|
|
65
|
-
clearTimeout(timeoutId);
|
|
66
|
-
if (signal) {
|
|
67
|
-
signal.removeEventListener("abort", onAbort);
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
return { signal: controller.signal, cleanup };
|
|
71
73
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
? Math.max(0, Math.floor(params.maxRedirects))
|
|
79
|
-
: DEFAULT_MAX_REDIRECTS;
|
|
80
|
-
const mode = resolveGuardedFetchMode(params);
|
|
81
|
-
const { signal, cleanup } = buildAbortSignal({
|
|
82
|
-
timeoutMs: params.timeoutMs,
|
|
83
|
-
signal: params.signal,
|
|
84
|
-
});
|
|
85
|
-
let released = false;
|
|
86
|
-
const release = async (dispatcher) => {
|
|
87
|
-
if (released) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
released = true;
|
|
91
|
-
cleanup();
|
|
92
|
-
await closeDispatcher(dispatcher ?? undefined);
|
|
93
|
-
};
|
|
94
|
-
const visited = new Set();
|
|
95
|
-
let currentUrl = params.url;
|
|
96
|
-
let currentInit = params.init ? { ...params.init } : undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Fetch with SSRF protection (legacy API)
|
|
76
|
+
*/
|
|
77
|
+
export async function fetchWithGuard(url, init, options = {}) {
|
|
78
|
+
const { maxRedirects = DEFAULT_MAX_REDIRECTS, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
|
|
79
|
+
let currentUrl = typeof url === "string" ? new URL(url) : url;
|
|
97
80
|
let redirectCount = 0;
|
|
98
81
|
while (true) {
|
|
99
|
-
|
|
82
|
+
// Validate URL
|
|
83
|
+
validateURL(currentUrl, options);
|
|
84
|
+
// Create abort controller for timeout
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
87
|
try {
|
|
101
|
-
|
|
88
|
+
const response = await fetch(currentUrl.toString(), {
|
|
89
|
+
...init,
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
redirect: "manual", // Handle redirects manually
|
|
92
|
+
});
|
|
93
|
+
clearTimeout(timeoutId);
|
|
94
|
+
// Handle redirects
|
|
95
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
96
|
+
redirectCount++;
|
|
97
|
+
if (redirectCount > maxRedirects) {
|
|
98
|
+
throw new FetchGuardError("redirect_limit", `Too many redirects (${maxRedirects})`);
|
|
99
|
+
}
|
|
100
|
+
const location = response.headers.get("location");
|
|
101
|
+
if (!location) {
|
|
102
|
+
return response;
|
|
103
|
+
}
|
|
104
|
+
// Resolve relative URLs
|
|
105
|
+
currentUrl = new URL(location, currentUrl);
|
|
106
|
+
// Validate redirect URL
|
|
107
|
+
validateURL(currentUrl, options);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
return response;
|
|
102
111
|
}
|
|
103
|
-
catch {
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
catch (error) {
|
|
113
|
+
clearTimeout(timeoutId);
|
|
114
|
+
if (error instanceof FetchGuardError) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
118
|
+
throw new FetchGuardError("timeout", `Request timeout (${timeoutMs}ms)`);
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
106
121
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Create a fetch function with guard options pre-configured
|
|
126
|
+
*/
|
|
127
|
+
export function createGuardedFetch(options = {}) {
|
|
128
|
+
return (url, init) => fetchWithGuard(url, init, options);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Fetch with strict SSRF protection (no private IPs)
|
|
132
|
+
*/
|
|
133
|
+
export function fetchWithStrictGuard(url, init) {
|
|
134
|
+
return fetchWithGuard(url, init, {
|
|
135
|
+
allowPrivateIPs: false,
|
|
136
|
+
allowedProtocols: ["https:"], // Only HTTPS
|
|
137
|
+
maxRedirects: 3,
|
|
138
|
+
timeoutMs: 15000,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Fetch with SSRF protection - main API used throughout the codebase
|
|
143
|
+
* Returns { response, release } for proper resource cleanup
|
|
144
|
+
*/
|
|
145
|
+
export async function fetchWithSsrFGuard(options) {
|
|
146
|
+
const { url, init, policy, lookupFn = dnsLookup, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
|
|
147
|
+
const parsedUrl = new URL(url);
|
|
148
|
+
// Check for blocked hostnames/IPs using the SSRF policy
|
|
149
|
+
const hostname = parsedUrl.hostname;
|
|
150
|
+
// For IP addresses, check if private
|
|
151
|
+
if (isIP(hostname)) {
|
|
152
|
+
if (isBlockedHostnameOrIp(hostname, policy)) {
|
|
153
|
+
throw new FetchGuardError("ssrf_blocked", `Access to ${hostname} is blocked by SSRF policy`);
|
|
110
154
|
}
|
|
111
|
-
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// For hostnames, resolve and check
|
|
112
158
|
try {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
159
|
+
const addresses = await lookupFn(hostname);
|
|
160
|
+
const ips = Array.isArray(addresses)
|
|
161
|
+
? addresses.map(a => typeof a === "string" ? a : a.address)
|
|
162
|
+
: [addresses];
|
|
163
|
+
for (const ip of ips) {
|
|
164
|
+
if (isBlockedHostnameOrIp(ip, policy)) {
|
|
165
|
+
throw new FetchGuardError("ssrf_blocked", `Access to ${hostname} (${ip}) is blocked by SSRF policy`);
|
|
166
|
+
}
|
|
120
167
|
}
|
|
121
|
-
|
|
122
|
-
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
// If lookup fails, continue with the request (fetch will fail appropriately)
|
|
171
|
+
if (error instanceof FetchGuardError) {
|
|
172
|
+
throw error;
|
|
123
173
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
const nextParsedUrl = new URL(location, parsedUrl);
|
|
143
|
-
const nextUrl = nextParsedUrl.toString();
|
|
144
|
-
if (visited.has(nextUrl)) {
|
|
145
|
-
await release(dispatcher);
|
|
146
|
-
throw new Error("Redirect loop detected");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Create abort controller for timeout
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
179
|
+
try {
|
|
180
|
+
const response = await fetchImpl(url, {
|
|
181
|
+
...init,
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
});
|
|
184
|
+
clearTimeout(timeoutId);
|
|
185
|
+
return {
|
|
186
|
+
response,
|
|
187
|
+
release: async () => {
|
|
188
|
+
// Clean up any resources if needed
|
|
189
|
+
try {
|
|
190
|
+
await response.body?.cancel();
|
|
147
191
|
}
|
|
148
|
-
|
|
149
|
-
|
|
192
|
+
catch {
|
|
193
|
+
// Ignore cleanup errors
|
|
150
194
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
finalUrl: currentUrl,
|
|
160
|
-
release: async () => release(dispatcher),
|
|
161
|
-
};
|
|
195
|
+
},
|
|
196
|
+
finalUrl: url,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
clearTimeout(timeoutId);
|
|
201
|
+
if (error instanceof FetchGuardError) {
|
|
202
|
+
throw error;
|
|
162
203
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const context = params.auditContext ?? "url-fetch";
|
|
166
|
-
logWarn(`security: blocked URL fetch (${context}) target=${parsedUrl.origin}${parsedUrl.pathname} reason=${err.message}`);
|
|
167
|
-
}
|
|
168
|
-
await release(dispatcher);
|
|
169
|
-
throw err;
|
|
204
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
205
|
+
throw new FetchGuardError("timeout", `Request timeout (${timeoutMs}ms)`);
|
|
170
206
|
}
|
|
207
|
+
throw error;
|
|
171
208
|
}
|
|
172
209
|
}
|
|
210
|
+
// Make compatible with typeof fetch for drop-in replacement
|
|
211
|
+
export const guardedFetch = Object.assign((input, init) => {
|
|
212
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input : input.url;
|
|
213
|
+
return fetchWithGuard(url, init);
|
|
214
|
+
}, {
|
|
215
|
+
isHttp: (_input) => true,
|
|
216
|
+
preconnect: async (_url) => { },
|
|
217
|
+
});
|