alvin-bot 5.3.0 → 5.5.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 +68 -3
- package/README.md +2 -0
- package/alvin-bot.config.example.json +1 -1
- package/dist/config.js +7 -4
- package/dist/handlers/commands.js +10 -2
- package/dist/handlers/document.js +8 -1
- package/dist/handlers/message.js +173 -30
- package/dist/i18n.js +21 -0
- package/dist/index.js +19 -1
- package/dist/init-data-dir.js +17 -0
- package/dist/middleware/auth.js +19 -1
- package/dist/providers/tool-executor.js +29 -4
- package/dist/services/async-agent-watcher.js +105 -14
- 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 +21 -3
- package/dist/services/session.js +3 -0
- package/dist/services/ssrf-guard.js +162 -0
- package/dist/services/steer-channel.js +7 -2
- package/dist/services/subagent-delivery.js +31 -8
- package/dist/services/telegram.js +9 -0
- package/dist/services/trends.js +202 -2
- package/dist/services/voice.js +0 -3
- package/dist/web/server.js +155 -5
- package/package.json +8 -7
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.5.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
|
}
|