alvin-bot 5.2.0 → 5.4.0
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/.env.example +100 -0
- package/CHANGELOG.md +76 -3
- package/README.md +2 -0
- package/alvin-bot.config.example.json +1 -1
- package/dist/config.js +15 -4
- package/dist/handlers/document.js +8 -1
- package/dist/handlers/message.js +165 -7
- package/dist/i18n.js +22 -0
- package/dist/index.js +12 -0
- package/dist/init-data-dir.js +17 -0
- package/dist/middleware/auth.js +19 -1
- package/dist/providers/claude-sdk-provider.js +3 -1
- package/dist/providers/tool-executor.js +29 -4
- package/dist/services/async-agent-watcher.js +52 -8
- package/dist/services/browser-manager.js +11 -9
- package/dist/services/browser-webfetch.js +47 -13
- package/dist/services/cron-scheduling.js +79 -19
- package/dist/services/cron.js +205 -16
- package/dist/services/delivery-queue.js +19 -0
- package/dist/services/embeddings/index.js +2 -5
- package/dist/services/env-file.js +4 -0
- package/dist/services/personality.js +40 -37
- package/dist/services/session-persistence.js +23 -3
- package/dist/services/session.js +9 -0
- package/dist/services/ssrf-guard.js +162 -0
- package/dist/services/steer-channel.js +46 -0
- package/dist/services/voice.js +0 -3
- package/dist/web/server.js +155 -5
- package/package.json +8 -7
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF Guard — rejects requests to private/internal network destinations.
|
|
3
|
+
*
|
|
4
|
+
* Blocks:
|
|
5
|
+
* - Non-http(s) schemes (file://, ftp://, etc.)
|
|
6
|
+
* - IPv4 loopback (127.0.0.0/8), link-local (169.254.0.0/16 — incl. cloud
|
|
7
|
+
* metadata endpoint 169.254.169.254), RFC-1918 private ranges
|
|
8
|
+
* (10/8, 172.16/12, 192.168/16), and the catch-all 0.0.0.0
|
|
9
|
+
* - IPv6 loopback (::1), ULA (fc00::/7), link-local (fe80::/10)
|
|
10
|
+
*
|
|
11
|
+
* Opt-out: set ALLOW_PRIVATE_FETCH=1 to disable blocking (for local dev /
|
|
12
|
+
* self-hosted setups). Default = blocked.
|
|
13
|
+
*
|
|
14
|
+
* No new runtime dependencies — uses Node's built-in `dns` and `net` modules.
|
|
15
|
+
*/
|
|
16
|
+
import dns from "dns";
|
|
17
|
+
import net from "net";
|
|
18
|
+
export class SsrfBlockedError extends Error {
|
|
19
|
+
url;
|
|
20
|
+
constructor(url, reason) {
|
|
21
|
+
super(`SSRF blocked: ${reason} (url: ${url})`);
|
|
22
|
+
this.name = "SsrfBlockedError";
|
|
23
|
+
this.url = url;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return true if the given IPv4 address string falls into a private/reserved
|
|
28
|
+
* range (RFC-1918, loopback, link-local, unspecified).
|
|
29
|
+
*
|
|
30
|
+
* Ranges blocked:
|
|
31
|
+
* 0.0.0.0/8 — "this" network
|
|
32
|
+
* 10.0.0.0/8 — RFC-1918 class A
|
|
33
|
+
* 127.0.0.0/8 — loopback
|
|
34
|
+
* 169.254.0.0/16 — link-local (incl. IMDS 169.254.169.254)
|
|
35
|
+
* 172.16.0.0/12 — RFC-1918 class B (172.16–172.31)
|
|
36
|
+
* 192.168.0.0/16 — RFC-1918 class C
|
|
37
|
+
*/
|
|
38
|
+
function isPrivateIPv4(ip) {
|
|
39
|
+
const parts = ip.split(".").map(Number);
|
|
40
|
+
if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255)) {
|
|
41
|
+
return false; // not a valid IPv4 — let DNS resolve decide
|
|
42
|
+
}
|
|
43
|
+
const [a, b] = parts;
|
|
44
|
+
if (a === 0)
|
|
45
|
+
return true; // 0.0.0.0/8
|
|
46
|
+
if (a === 10)
|
|
47
|
+
return true; // 10.0.0.0/8
|
|
48
|
+
if (a === 127)
|
|
49
|
+
return true; // 127.0.0.0/8 loopback
|
|
50
|
+
if (a === 169 && b === 254)
|
|
51
|
+
return true; // 169.254.0.0/16 link-local
|
|
52
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
53
|
+
return true; // 172.16.0.0/12
|
|
54
|
+
if (a === 192 && b === 168)
|
|
55
|
+
return true; // 192.168.0.0/16
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Return true if the given IPv6 address string falls into a blocked range.
|
|
60
|
+
*
|
|
61
|
+
* Ranges blocked:
|
|
62
|
+
* ::1 — loopback
|
|
63
|
+
* fc00::/7 — ULA (fc00:: – fdff::)
|
|
64
|
+
* fe80::/10 — link-local
|
|
65
|
+
*/
|
|
66
|
+
function isPrivateIPv6(ip) {
|
|
67
|
+
// Node net.isIPv6 normalises the address but we need the raw string
|
|
68
|
+
// for prefix checks. Use the compressed form from net.
|
|
69
|
+
const normalized = ip.toLowerCase().replace(/^\[|\]$/g, "");
|
|
70
|
+
// Loopback
|
|
71
|
+
if (normalized === "::1")
|
|
72
|
+
return true;
|
|
73
|
+
// For prefix checks, expand just the first 16-bit group
|
|
74
|
+
const firstGroup = normalized.split(":")[0];
|
|
75
|
+
const value = parseInt(firstGroup || "0", 16);
|
|
76
|
+
// fc00::/7 — fc00 to fdff (bit 7 of first byte = 1, bit 6 = 1, bit 5 doesn't matter… easier: 0xfc00–0xfdff range for the first 16 bits)
|
|
77
|
+
// The /7 prefix means: binary prefix 1111110x — so 0xfc00 to 0xfdff
|
|
78
|
+
if (value >= 0xfc00 && value <= 0xfdff)
|
|
79
|
+
return true;
|
|
80
|
+
// fe80::/10 — fe80 to febf
|
|
81
|
+
if (value >= 0xfe80 && value <= 0xfebf)
|
|
82
|
+
return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Check whether a raw IP string (v4 or v6) is a private/internal address.
|
|
87
|
+
*/
|
|
88
|
+
function isPrivateIP(ip) {
|
|
89
|
+
const stripped = ip.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
90
|
+
if (net.isIPv4(stripped))
|
|
91
|
+
return isPrivateIPv4(stripped);
|
|
92
|
+
if (net.isIPv6(stripped))
|
|
93
|
+
return isPrivateIPv6(stripped);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check the hostname from a URL — if it's a literal IP, classify immediately.
|
|
98
|
+
* If it's a hostname, resolve it via DNS and check every returned address.
|
|
99
|
+
*
|
|
100
|
+
* Throws SsrfBlockedError if any resolved address is private.
|
|
101
|
+
* Resolves void if all addresses are public (or DNS fails — fail-open on DNS
|
|
102
|
+
* errors so a temporary resolver blip doesn't DoS the bot; the risk is low
|
|
103
|
+
* because the per-host literal-IP checks run before DNS).
|
|
104
|
+
*/
|
|
105
|
+
async function checkHost(hostname, url) {
|
|
106
|
+
const bare = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets for net.isIP
|
|
107
|
+
// Literal IP — no DNS needed
|
|
108
|
+
if (net.isIP(bare)) {
|
|
109
|
+
if (isPrivateIP(bare)) {
|
|
110
|
+
throw new SsrfBlockedError(url, `destination ${bare} is a private/loopback/link-local address`);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Named hostname — also catch obvious loopback hostnames without DNS
|
|
115
|
+
const lower = bare.toLowerCase();
|
|
116
|
+
if (lower === "localhost" || lower.endsWith(".localhost") || lower === "::1") {
|
|
117
|
+
throw new SsrfBlockedError(url, `destination hostname '${bare}' resolves to loopback`);
|
|
118
|
+
}
|
|
119
|
+
// DNS resolution — check every returned address
|
|
120
|
+
try {
|
|
121
|
+
const { promises: dnsPromises } = dns;
|
|
122
|
+
const addresses = await dnsPromises.resolve(bare);
|
|
123
|
+
for (const addr of addresses) {
|
|
124
|
+
if (isPrivateIP(addr)) {
|
|
125
|
+
throw new SsrfBlockedError(url, `destination hostname '${bare}' resolves to private address ${addr}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
// Re-throw our own error; swallow DNS failures (fail-open)
|
|
131
|
+
if (err instanceof SsrfBlockedError)
|
|
132
|
+
throw err;
|
|
133
|
+
// DNS error (ENOTFOUND, etc.) — let the actual fetch fail naturally
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Assert that `url` is safe to fetch (not SSRF-risky).
|
|
138
|
+
*
|
|
139
|
+
* - Rejects non-http(s) schemes immediately (synchronous check).
|
|
140
|
+
* - Resolves hostnames to detect private IP destinations.
|
|
141
|
+
* - Respects ALLOW_PRIVATE_FETCH=1 for operator opt-out.
|
|
142
|
+
*
|
|
143
|
+
* Throws SsrfBlockedError when blocked.
|
|
144
|
+
* Resolves void when safe.
|
|
145
|
+
*/
|
|
146
|
+
export async function assertSsrfSafe(url) {
|
|
147
|
+
// Opt-out for trusted local / self-hosted environments
|
|
148
|
+
if (process.env.ALLOW_PRIVATE_FETCH === "1")
|
|
149
|
+
return;
|
|
150
|
+
let parsed;
|
|
151
|
+
try {
|
|
152
|
+
parsed = new URL(url);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new SsrfBlockedError(url, "invalid URL");
|
|
156
|
+
}
|
|
157
|
+
const scheme = parsed.protocol; // includes trailing ':'
|
|
158
|
+
if (scheme !== "http:" && scheme !== "https:") {
|
|
159
|
+
throw new SsrfBlockedError(url, `scheme '${parsed.protocol}' is not allowed (only http/https)`);
|
|
160
|
+
}
|
|
161
|
+
await checkHost(parsed.hostname, url);
|
|
162
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const DEFAULT_CAP = 20;
|
|
2
|
+
export class SteerChannel {
|
|
3
|
+
cap;
|
|
4
|
+
buf = [];
|
|
5
|
+
closed = false;
|
|
6
|
+
resolveNext = null;
|
|
7
|
+
constructor(cap = DEFAULT_CAP) {
|
|
8
|
+
this.cap = cap;
|
|
9
|
+
}
|
|
10
|
+
/** Push a message into the channel.
|
|
11
|
+
* Returns true if the message was accepted, false if it was dropped
|
|
12
|
+
* (channel closed or buffer cap reached). Callers must check the
|
|
13
|
+
* return value to decide whether to send a 📨 ack or a bufferFull notice. */
|
|
14
|
+
push(text) {
|
|
15
|
+
if (this.closed)
|
|
16
|
+
return false;
|
|
17
|
+
if (this.buf.length >= this.cap) {
|
|
18
|
+
console.warn(`[steer-channel] cap ${this.cap} reached — dropping steer message`);
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
this.buf.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null });
|
|
22
|
+
const r = this.resolveNext;
|
|
23
|
+
this.resolveNext = null;
|
|
24
|
+
r?.();
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
close() {
|
|
28
|
+
if (this.closed)
|
|
29
|
+
return;
|
|
30
|
+
this.closed = true;
|
|
31
|
+
const r = this.resolveNext;
|
|
32
|
+
this.resolveNext = null;
|
|
33
|
+
r?.();
|
|
34
|
+
}
|
|
35
|
+
async *[Symbol.asyncIterator]() {
|
|
36
|
+
while (true) {
|
|
37
|
+
if (this.buf.length > 0) {
|
|
38
|
+
yield this.buf.shift();
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (this.closed)
|
|
42
|
+
return;
|
|
43
|
+
await new Promise((res) => { this.resolveNext = res; });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/services/voice.js
CHANGED
|
@@ -21,9 +21,6 @@ export async function transcribeAudio(audioPath) {
|
|
|
21
21
|
const bodyEnd = Buffer.from(`\r\n--${boundary}\r\n` +
|
|
22
22
|
`Content-Disposition: form-data; name="model"\r\n\r\n` +
|
|
23
23
|
`whisper-large-v3-turbo\r\n` +
|
|
24
|
-
`--${boundary}\r\n` +
|
|
25
|
-
`Content-Disposition: form-data; name="language"\r\n\r\n` +
|
|
26
|
-
`de\r\n` +
|
|
27
24
|
`--${boundary}--\r\n`, "utf-8");
|
|
28
25
|
const fullBody = Buffer.concat([bodyStart, fileBuffer, bodyEnd]);
|
|
29
26
|
return new Promise((resolve, reject) => {
|
package/dist/web/server.js
CHANGED
|
@@ -77,6 +77,18 @@ function checkAuth(req) {
|
|
|
77
77
|
const token = cookie.match(/alvinbot_token=([a-f0-9]+)/)?.[1];
|
|
78
78
|
return token ? activeSessions.has(token) : false;
|
|
79
79
|
}
|
|
80
|
+
/** Returns true when the server is exposed to non-loopback addresses but
|
|
81
|
+
* WEB_PASSWORD is empty. In that state every mutating / exec endpoint is
|
|
82
|
+
* hard-refused regardless of any session cookie. */
|
|
83
|
+
function isExposedWithoutPassword() {
|
|
84
|
+
if (WEB_PASSWORD)
|
|
85
|
+
return false; // password set → normal auth path
|
|
86
|
+
const h = config.webHost;
|
|
87
|
+
// loopback addresses are safe even without a password
|
|
88
|
+
if (!h || h === "127.0.0.1" || h === "::1" || h === "localhost")
|
|
89
|
+
return false;
|
|
90
|
+
return true; // non-loopback + no password = exposed
|
|
91
|
+
}
|
|
80
92
|
// ── REST API ────────────────────────────────────────────
|
|
81
93
|
async function handleAPI(req, res, urlPath, body) {
|
|
82
94
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -84,7 +96,14 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
84
96
|
if (urlPath === "/api/login" && req.method === "POST") {
|
|
85
97
|
try {
|
|
86
98
|
const { password } = JSON.parse(body);
|
|
87
|
-
|
|
99
|
+
// v5.x — refuse login entirely when WEB_PASSWORD is not set.
|
|
100
|
+
// Previously `!WEB_PASSWORD || password === WEB_PASSWORD` minted a
|
|
101
|
+
// session for ANY input (including "") when the env var was absent,
|
|
102
|
+
// effectively making every unauthenticated request a valid session.
|
|
103
|
+
// timingSafeBearerMatch already rejects empty expectedToken → reuse it
|
|
104
|
+
// by wrapping the provided password as a synthetic "Bearer <pw>" header.
|
|
105
|
+
const authOk = timingSafeBearerMatch(`Bearer ${password}`, WEB_PASSWORD);
|
|
106
|
+
if (authOk) {
|
|
88
107
|
const token = generateToken();
|
|
89
108
|
activeSessions.add(token);
|
|
90
109
|
res.setHeader("Set-Cookie", `alvinbot_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
|
@@ -141,6 +160,66 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
141
160
|
res.end(JSON.stringify({ error: "Not authenticated" }));
|
|
142
161
|
return;
|
|
143
162
|
}
|
|
163
|
+
// Hard gate: when the web server is reachable from non-loopback addresses
|
|
164
|
+
// and WEB_PASSWORD is empty, mutating/exec endpoints are refused outright.
|
|
165
|
+
// Local dev (127.0.0.1 + no password) is intentionally unaffected.
|
|
166
|
+
// Endpoints that write files, execute commands, or modify configuration
|
|
167
|
+
// are in scope; read-only status/config endpoints are not gated.
|
|
168
|
+
const EXPOSED_DANGEROUS_ROUTES = new Set([
|
|
169
|
+
"/api/terminal",
|
|
170
|
+
"/api/skills/create",
|
|
171
|
+
"/api/skills/update",
|
|
172
|
+
"/api/skills/delete",
|
|
173
|
+
"/api/files/save",
|
|
174
|
+
"/api/files/delete",
|
|
175
|
+
"/api/env/set",
|
|
176
|
+
"/api/setup-wizard",
|
|
177
|
+
"/api/soul/save",
|
|
178
|
+
"/api/memory/save",
|
|
179
|
+
"/api/memory/delete",
|
|
180
|
+
"/api/session/reset",
|
|
181
|
+
// cron — /api/cron/add was wrong; real route is /api/cron/create
|
|
182
|
+
"/api/cron/create",
|
|
183
|
+
"/api/cron/update",
|
|
184
|
+
"/api/cron/delete",
|
|
185
|
+
"/api/cron/toggle",
|
|
186
|
+
"/api/cron/run",
|
|
187
|
+
"/api/plugin/install",
|
|
188
|
+
"/api/plugin/uninstall",
|
|
189
|
+
// shell / process exec
|
|
190
|
+
"/api/tools/execute",
|
|
191
|
+
"/api/sudo/exec",
|
|
192
|
+
"/api/sudo/setup",
|
|
193
|
+
"/api/sudo/admin-dialog",
|
|
194
|
+
"/api/sudo/revoke",
|
|
195
|
+
// key / env writes
|
|
196
|
+
"/api/providers/set-key",
|
|
197
|
+
"/api/providers/set-primary",
|
|
198
|
+
"/api/providers/set-fallbacks",
|
|
199
|
+
"/api/providers/add-custom",
|
|
200
|
+
"/api/providers/remove-custom",
|
|
201
|
+
// platform config writes (writes ENV vars / runs npm install)
|
|
202
|
+
"/api/platforms/configure",
|
|
203
|
+
"/api/platforms/install-deps",
|
|
204
|
+
// provider / fallback order writes
|
|
205
|
+
"/api/fallback",
|
|
206
|
+
"/api/fallback/move",
|
|
207
|
+
// MCP config writes
|
|
208
|
+
"/api/mcp/add",
|
|
209
|
+
"/api/mcp/remove",
|
|
210
|
+
// process restart (DoS vector)
|
|
211
|
+
"/api/restart",
|
|
212
|
+
// model switching (affects all users)
|
|
213
|
+
"/api/models/switch",
|
|
214
|
+
// WhatsApp auth / config writes
|
|
215
|
+
"/api/whatsapp/disconnect",
|
|
216
|
+
"/api/whatsapp/group-rules",
|
|
217
|
+
]);
|
|
218
|
+
if (isExposedWithoutPassword() && EXPOSED_DANGEROUS_ROUTES.has(urlPath)) {
|
|
219
|
+
res.statusCode = 403;
|
|
220
|
+
res.end(JSON.stringify({ error: "refused: web exposed without WEB_PASSWORD" }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
144
223
|
// ── Setup APIs (platforms + models) ─────────────────
|
|
145
224
|
const handled = await handleSetupAPI(req, res, urlPath, body);
|
|
146
225
|
if (handled)
|
|
@@ -201,6 +280,9 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
201
280
|
const envPath = ENV_FILE;
|
|
202
281
|
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "";
|
|
203
282
|
const setEnv = (key, value) => {
|
|
283
|
+
// M6 (centralized): reject values containing newline characters
|
|
284
|
+
if (/[\n\r]/.test(value))
|
|
285
|
+
throw new Error("env value must not contain newline characters");
|
|
204
286
|
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
205
287
|
if (regex.test(content)) {
|
|
206
288
|
content = content.replace(regex, `${key}=${value}`);
|
|
@@ -509,6 +591,12 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
509
591
|
}
|
|
510
592
|
// DELETE /api/users/:id — Kill session + delete user data
|
|
511
593
|
if (urlPath.startsWith("/api/users/") && req.method === "DELETE") {
|
|
594
|
+
// Pattern route — gate here since EXPOSED_DANGEROUS_ROUTES can't express :id
|
|
595
|
+
if (isExposedWithoutPassword()) {
|
|
596
|
+
res.statusCode = 403;
|
|
597
|
+
res.end(JSON.stringify({ error: "refused: web exposed without WEB_PASSWORD" }));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
512
600
|
const userId = parseInt(urlPath.split("/").pop() || "0");
|
|
513
601
|
if (!userId) {
|
|
514
602
|
res.statusCode = 400;
|
|
@@ -701,8 +789,27 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
701
789
|
res.end(JSON.stringify({ error: "id and name required" }));
|
|
702
790
|
return;
|
|
703
791
|
}
|
|
792
|
+
// Security: reject id values that could escape SKILLS_DIR.
|
|
793
|
+
// Mirror the resolve+startsWith containment pattern used in /api/files/save (server.ts ~:921).
|
|
794
|
+
// Also guard against ids with path separators or absolute paths before
|
|
795
|
+
// resolve() ever runs, so the raw string never reaches the filesystem.
|
|
796
|
+
if (typeof id !== "string" ||
|
|
797
|
+
id.includes("..") ||
|
|
798
|
+
id.includes("/") ||
|
|
799
|
+
id.includes("\\") ||
|
|
800
|
+
path.isAbsolute(id)) {
|
|
801
|
+
res.statusCode = 400;
|
|
802
|
+
res.end(JSON.stringify({ error: "Invalid skill id" }));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
704
805
|
const skillsDir = SKILLS_DIR;
|
|
705
806
|
const skillDir = resolve(skillsDir, id);
|
|
807
|
+
// Belt-and-suspenders: resolved path must still be inside SKILLS_DIR
|
|
808
|
+
if (!skillDir.startsWith(skillsDir)) {
|
|
809
|
+
res.statusCode = 400;
|
|
810
|
+
res.end(JSON.stringify({ error: "Invalid skill id" }));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
706
813
|
if (!fs.existsSync(skillDir))
|
|
707
814
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
708
815
|
const frontmatter = [
|
|
@@ -730,6 +837,22 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
730
837
|
if (urlPath === "/api/skills/update" && req.method === "POST") {
|
|
731
838
|
try {
|
|
732
839
|
const { id, content } = JSON.parse(body);
|
|
840
|
+
// Security: same id containment as /api/skills/create
|
|
841
|
+
if (typeof id !== "string" ||
|
|
842
|
+
id.includes("..") ||
|
|
843
|
+
id.includes("/") ||
|
|
844
|
+
id.includes("\\") ||
|
|
845
|
+
path.isAbsolute(id)) {
|
|
846
|
+
res.statusCode = 400;
|
|
847
|
+
res.end(JSON.stringify({ error: "Invalid skill id" }));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
// Belt-and-suspenders: resolved path must still be inside SKILLS_DIR
|
|
851
|
+
if (!resolve(SKILLS_DIR, id).startsWith(SKILLS_DIR)) {
|
|
852
|
+
res.statusCode = 400;
|
|
853
|
+
res.end(JSON.stringify({ error: "Invalid skill id" }));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
733
856
|
const skillPath = resolve(SKILLS_DIR, id, "SKILL.md");
|
|
734
857
|
if (!fs.existsSync(skillPath)) {
|
|
735
858
|
// Try flat file
|
|
@@ -760,6 +883,16 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
760
883
|
if (urlPath === "/api/skills/delete" && req.method === "POST") {
|
|
761
884
|
try {
|
|
762
885
|
const { id } = JSON.parse(body);
|
|
886
|
+
// Security: same raw-string id containment as /api/skills/create + /api/skills/update
|
|
887
|
+
if (typeof id !== "string" ||
|
|
888
|
+
id.includes("..") ||
|
|
889
|
+
id.includes("/") ||
|
|
890
|
+
id.includes("\\") ||
|
|
891
|
+
path.isAbsolute(id)) {
|
|
892
|
+
res.statusCode = 400;
|
|
893
|
+
res.end(JSON.stringify({ error: "Invalid skill id" }));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
763
896
|
const skillDir = resolve(SKILLS_DIR, id);
|
|
764
897
|
const flatFile = resolve(SKILLS_DIR, id + ".md");
|
|
765
898
|
if (fs.existsSync(skillDir)) {
|
|
@@ -1036,6 +1169,13 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
1036
1169
|
res.end(JSON.stringify({ error: "Invalid key name" }));
|
|
1037
1170
|
return;
|
|
1038
1171
|
}
|
|
1172
|
+
// M6: reject values containing newline characters — they allow injecting
|
|
1173
|
+
// extra .env lines (e.g. value="good\nEVIL=injected").
|
|
1174
|
+
if (typeof value === "string" && /[\n\r]/.test(value)) {
|
|
1175
|
+
res.statusCode = 400;
|
|
1176
|
+
res.end(JSON.stringify({ error: "Invalid value: newline characters not allowed" }));
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1039
1179
|
let envContent = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
|
|
1040
1180
|
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
1041
1181
|
if (regex.test(envContent)) {
|
|
@@ -1182,6 +1322,12 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
1182
1322
|
}
|
|
1183
1323
|
// DELETE /api/whatsapp/group-rules/:id — delete a group rule
|
|
1184
1324
|
if (urlPath.match(/^\/api\/whatsapp\/group-rules\//) && req.method === "DELETE") {
|
|
1325
|
+
// Pattern route — gate here since EXPOSED_DANGEROUS_ROUTES can't express :id
|
|
1326
|
+
if (isExposedWithoutPassword()) {
|
|
1327
|
+
res.statusCode = 403;
|
|
1328
|
+
res.end(JSON.stringify({ error: "refused: web exposed without WEB_PASSWORD" }));
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1185
1331
|
const groupId = decodeURIComponent(urlPath.split("/").slice(4).join("/"));
|
|
1186
1332
|
const { deleteGroupRule } = await import("../platforms/whatsapp.js");
|
|
1187
1333
|
const ok = deleteGroupRule(groupId);
|
|
@@ -1498,7 +1644,7 @@ function handleWebRequest(req, res) {
|
|
|
1498
1644
|
*/
|
|
1499
1645
|
export function startWebServer() {
|
|
1500
1646
|
stopRequested = false;
|
|
1501
|
-
scheduleBindAttempt(WEB_PORT, 0);
|
|
1647
|
+
scheduleBindAttempt(parseInt(process.env.WEB_PORT || "3100", 10), 0);
|
|
1502
1648
|
}
|
|
1503
1649
|
function scheduleBindAttempt(port, attempt) {
|
|
1504
1650
|
if (stopRequested)
|
|
@@ -1598,9 +1744,13 @@ function scheduleBindAttempt(port, attempt) {
|
|
|
1598
1744
|
if (actualWebPort !== originalPort) {
|
|
1599
1745
|
console.log(` (Port ${originalPort} was busy, using ${actualWebPort} instead)`);
|
|
1600
1746
|
}
|
|
1601
|
-
if (
|
|
1602
|
-
|
|
1603
|
-
|
|
1747
|
+
if (isExposedWithoutPassword()) {
|
|
1748
|
+
// Upgrade from warn to a clear one-time log: the hard gate (above in
|
|
1749
|
+
// handleAPI) already blocks every mutating/exec route in this state,
|
|
1750
|
+
// so this message explains why those calls are being refused.
|
|
1751
|
+
console.log("[web] Non-loopback host with no WEB_PASSWORD: mutating/exec endpoints are disabled " +
|
|
1752
|
+
"(hard-refused 403). Set WEB_PASSWORD in ~/.alvin-bot/.env to unlock them, " +
|
|
1753
|
+
"or restrict to loopback with WEB_HOST=127.0.0.1.");
|
|
1604
1754
|
}
|
|
1605
1755
|
});
|
|
1606
1756
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alvin-bot",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -166,11 +166,9 @@
|
|
|
166
166
|
"url": "git+https://github.com/alvbln/Alvin-Bot.git"
|
|
167
167
|
},
|
|
168
168
|
"dependencies": {
|
|
169
|
-
"@anthropic-ai/claude-agent-sdk": "
|
|
169
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.142",
|
|
170
170
|
"@hapi/boom": "^10.0.1",
|
|
171
171
|
"@slack/bolt": "^4.6.0",
|
|
172
|
-
"@types/node": "^22.0.0",
|
|
173
|
-
"@types/ws": "^8.18.1",
|
|
174
172
|
"@whiskeysockets/baileys": "^6.7.21",
|
|
175
173
|
"better-sqlite3": "^12.9.0",
|
|
176
174
|
"dotenv": "^16.4.0",
|
|
@@ -179,16 +177,18 @@
|
|
|
179
177
|
"node-edge-tts": "^1.2.10",
|
|
180
178
|
"pino": "^10.3.1",
|
|
181
179
|
"playwright": "^1.58.2",
|
|
182
|
-
"typescript": "^5.9.3",
|
|
183
180
|
"whatsapp-web.js": "^1.34.6",
|
|
184
181
|
"ws": "^8.19.0"
|
|
185
182
|
},
|
|
186
183
|
"devDependencies": {
|
|
187
184
|
"@types/better-sqlite3": "^7.6.13",
|
|
185
|
+
"@types/node": "^22.0.0",
|
|
186
|
+
"@types/ws": "^8.18.1",
|
|
188
187
|
"@vitest/ui": "^4.1.4",
|
|
189
188
|
"electron": "^35.7.5",
|
|
190
189
|
"electron-builder": "^26.8.1",
|
|
191
190
|
"tsx": "^4.19.0",
|
|
191
|
+
"typescript": "^5.9.3",
|
|
192
192
|
"vitest": "^4.1.4"
|
|
193
193
|
},
|
|
194
194
|
"homepage": "https://github.com/alvbln/Alvin-Bot",
|
|
@@ -196,9 +196,10 @@
|
|
|
196
196
|
"node": ">=18.0.0"
|
|
197
197
|
},
|
|
198
198
|
"bugs": {
|
|
199
|
-
"url": "https://github.com/alvbln/
|
|
199
|
+
"url": "https://github.com/alvbln/Alvin-Bot/issues"
|
|
200
200
|
},
|
|
201
201
|
"overrides": {
|
|
202
|
-
"axios": "
|
|
202
|
+
"axios": ">=1.8.2",
|
|
203
|
+
"protobufjs": ">=7.5.6"
|
|
203
204
|
}
|
|
204
205
|
}
|