Summary
The web server signs and verifies JSON Web Tokens with a fixed, publicly-known secret. Under the official deployment flow, the effective JWT_SECRET is a constant that ships in the repository — either the placeholder change-this-to-a-random-secret-string from .env.example, or the hardcoded fallback deepbot-default-secret-change-in-production when JWT_SECRET is unset. Anyone who knows that constant can forge a valid JWT for any userId, bypass the ACCESS_PASSWORD login check, and reach every protected API and the WebSocket. Because the same constant is reused as the X-Secret for /api/external, it also unlocks the external API. From there an attacker can drive the AI agent's bash tool to execute arbitrary commands on the server (verified in a local Docker deployment of this project, run as root).
Verdict: Confirmed via dynamic reproduction under the official Docker/Web runtime (node:22, NODE_ENV=production, DEEPBOT_DOCKER=true, .env.example defaults).
Affected components / prerequisites
src/server/middleware/auth.ts (lines 14, 39, 51)
src/server/websocket-manager.ts (lines 20, 105)
src/server/routes/external.ts (lines 17, 107)
.env.example (line 13)
- Prerequisites: service is network-reachable (docker-compose publishes
0.0.0.0:3008 by default). Attackers do not need any account or prior access.
Root cause
The JWT signing/verification secret is a public constant in every official default variant:
// src/server/middleware/auth.ts:14
const JWT_SECRET = process.env.JWT_SECRET || 'deepbot-default-secret-change-in-production';
# .env.example:13 (documented flow copies this to .env)
JWT_SECRET=change-this-to-a-random-secret-string
The same constant gates /api/external:
// src/server/routes/external.ts:17, 99-113
const JWT_SECRET = process.env.JWT_SECRET || 'deepbot-default-secret-change-in-production';
// if (secret !== JWT_SECRET) { 403 }
authMiddleware trusts any token that verifies with this constant:
// src/server/middleware/auth.ts:37-44
const token = authHeader.replace('Bearer ', '');
const decoded = jwt.verify(token, JWT_SECRET) as TokenPayload; // line 39
(req as AuthRequest).userId = decoded.userId;
next();
Impact
- Authentication bypass: forge
{userId: "default"} with the known constant (30-day expiry, matching generateToken) and send Authorization: Bearer <token> — GET /api/config returns 200 (no token returns 401). Applies to all /api/* endpoints mounted behind authMiddleware (config, tabs, tools, connectors, files, skills, tasks, token/image usage) and to the WebSocket (/ws?token=...).
- External API bypass:
X-Secret: <known constant> passes the /api/external gate (missing header → 401, wrong value → 403, known value → accepted).
- Remote code execution: with a configured AI model (required by the official
.env), a request through /api/tabs/:id/messages or /api/external/command reaches the agent, which can invoke the bash tool and execute arbitrary shell commands as root in the container. Verified locally: id, hostname, whoami, pwd all executed. The command path-check only confines file paths referenced inside a command to /data and /tmp; commands without such paths run unrestricted, and the dangerous-command blocklist is trivially bypassed.
- Data / credentials: system config, Tab history, connector configurations (WeChat / WeCom / Feishu / smart-KF credentials) and pairing records are readable/writable.
Reproduction (on a controlled local deployment)
Set up per the documented flow (copy .env.example to .env, docker compose up; then set an access password if desired). In a test environment:
- Forge a token exactly as
generateToken does, using the constant (e.g. jwt.sign({userId:'default'},'<JWT_SECRET>',{expiresIn:'30d'})).
curl http://<host>:3008/api/config with no token → 401.
curl -H "Authorization: Bearer <forged>" http://<host>:3008/api/config → 200 with the full system config.
- WebSocket:
ws://<host>:3008/ws?token=<forged> connects and answers pong; without a token it closes with code 1008.
/api/external/command with X-Secret: <constant> and a harmless command (hostname > /tmp/out; cat /tmp/out) returns success:true and the command output is written — arbitrary command execution.
Negative controls: a token forged with a random secret returns 401; with a strong random JWT_SECRET set by the operator, tokens forged with both known constants return 401 (confirming the root cause is the fixed/public secret, not the JWT verification logic). A wrong X-Secret returns 403.
Suggested remediation
- Remove all hardcoded JWT fallback values (
auth.ts:14, websocket-manager.ts:20, external.ts:17); require JWT_SECRET and fail closed at startup when it is absent. Document generating a strong random key (e.g. openssl rand -base64 48).
- Purge the placeholder
change-this-to-a-random-secret-string from the repository and make .env.example JWT_SECRET empty or required.
- Do not reuse the JWT secret as the
/api/external X-Secret; use a separate, random, required key with rotation.
- Add server-side token revocation (token version / jti denylist) so leaked tokens can be invalidated.
- Reassess exposing the agent's
bash (arbitrary shell) capability to network clients; confine execution to controlled directories with audit, or disable remote exec by default.
Notes / scope
- Verified on a local, isolated Docker deployment (no real API key used; the model-call step was exercised against a local mock endpoint, while the
bash tool and its spawn(shell:true) execution are this project's real code).
- The Electron desktop client uses the same auth code paths but was not separately run.
- I have not included a complete usable forged token in this issue.
Thanks for looking into this. Happy to provide additional detail or a reproduction environment.
Summary
The web server signs and verifies JSON Web Tokens with a fixed, publicly-known secret. Under the official deployment flow, the effective
JWT_SECRETis a constant that ships in the repository — either the placeholderchange-this-to-a-random-secret-stringfrom.env.example, or the hardcoded fallbackdeepbot-default-secret-change-in-productionwhenJWT_SECRETis unset. Anyone who knows that constant can forge a valid JWT for anyuserId, bypass theACCESS_PASSWORDlogin check, and reach every protected API and the WebSocket. Because the same constant is reused as theX-Secretfor/api/external, it also unlocks the external API. From there an attacker can drive the AI agent'sbashtool to execute arbitrary commands on the server (verified in a local Docker deployment of this project, run as root).Verdict: Confirmed via dynamic reproduction under the official Docker/Web runtime (
node:22,NODE_ENV=production,DEEPBOT_DOCKER=true,.env.exampledefaults).Affected components / prerequisites
src/server/middleware/auth.ts(lines 14, 39, 51)src/server/websocket-manager.ts(lines 20, 105)src/server/routes/external.ts(lines 17, 107).env.example(line 13)0.0.0.0:3008by default). Attackers do not need any account or prior access.Root cause
The JWT signing/verification secret is a public constant in every official default variant:
# .env.example:13 (documented flow copies this to .env) JWT_SECRET=change-this-to-a-random-secret-stringThe same constant gates
/api/external:authMiddlewaretrusts any token that verifies with this constant:Impact
{userId: "default"}with the known constant (30-day expiry, matchinggenerateToken) and sendAuthorization: Bearer <token>—GET /api/configreturns200(no token returns401). Applies to all/api/*endpoints mounted behindauthMiddleware(config, tabs, tools, connectors, files, skills, tasks, token/image usage) and to the WebSocket (/ws?token=...).X-Secret: <known constant>passes the/api/externalgate (missing header →401, wrong value →403, known value → accepted)..env), a request through/api/tabs/:id/messagesor/api/external/commandreaches the agent, which can invoke thebashtool and execute arbitrary shell commands as root in the container. Verified locally:id,hostname,whoami,pwdall executed. The command path-check only confines file paths referenced inside a command to/dataand/tmp; commands without such paths run unrestricted, and the dangerous-command blocklist is trivially bypassed.Reproduction (on a controlled local deployment)
Set up per the documented flow (copy
.env.exampleto.env,docker compose up; then set an access password if desired). In a test environment:generateTokendoes, using the constant (e.g.jwt.sign({userId:'default'},'<JWT_SECRET>',{expiresIn:'30d'})).curl http://<host>:3008/api/configwith no token →401.curl -H "Authorization: Bearer <forged>" http://<host>:3008/api/config→200with the full system config.ws://<host>:3008/ws?token=<forged>connects and answerspong; without a token it closes with code1008./api/external/commandwithX-Secret: <constant>and a harmless command (hostname > /tmp/out; cat /tmp/out) returnssuccess:trueand the command output is written — arbitrary command execution.Negative controls: a token forged with a random secret returns
401; with a strong randomJWT_SECRETset by the operator, tokens forged with both known constants return401(confirming the root cause is the fixed/public secret, not the JWT verification logic). A wrongX-Secretreturns403.Suggested remediation
auth.ts:14,websocket-manager.ts:20,external.ts:17); requireJWT_SECRETand fail closed at startup when it is absent. Document generating a strong random key (e.g.openssl rand -base64 48).change-this-to-a-random-secret-stringfrom the repository and make.env.exampleJWT_SECRETempty or required./api/externalX-Secret; use a separate, random, required key with rotation.bash(arbitrary shell) capability to network clients; confine execution to controlled directories with audit, or disable remote exec by default.Notes / scope
bashtool and itsspawn(shell:true)execution are this project's real code).Thanks for looking into this. Happy to provide additional detail or a reproduction environment.