alvin-bot 5.3.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.
@@ -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
- if (!WEB_PASSWORD || password === WEB_PASSWORD) {
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 (bindHost === "0.0.0.0" && !process.env.WEB_PASSWORD) {
1602
- console.warn("⚠️ Web UI is bound to 0.0.0.0 but WEB_PASSWORD is empty — anyone on the LAN can log in. " +
1603
- "Set WEB_PASSWORD in ~/.alvin-bot/.env or set WEB_HOST=127.0.0.1.");
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.0",
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": "^0.2.97",
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/alvin-bot/issues"
199
+ "url": "https://github.com/alvbln/Alvin-Bot/issues"
200
200
  },
201
201
  "overrides": {
202
- "axios": "^1.15.0"
202
+ "axios": ">=1.8.2",
203
+ "protobufjs": ">=7.5.6"
203
204
  }
204
205
  }