[Eclipse aeriOS] Keycloak credential database exposed externally with hardcoded password
> [!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/idm
</details>
<details>
<summary><strong>What are the affected versions?</strong></summary>
`idm` chart 1.1.0 (only published version). The docker-compose variant in the same commit.
</details>
<details open>
<summary><strong>Summary</strong></summary>
The `idm` Helm chart exposes the PostgreSQL backing store for Keycloak as a `NodePort` service by default, with hardcoded credentials `keycloak:password`. The chart provides no `existingSecret` mechanism for the database password — it is injected as a plain `env` value in both the StatefulSet and the Keycloak Deployment — so even a security-conscious operator cannot avoid the password landing in cleartext in Helm release history. An attacker who can reach any cluster node IP on the NodePort range can connect directly to the database, bypass Keycloak entirely, and read or modify realm signing keys, client secrets, user credentials and sessions. The Docker Compose variant has the equivalent flaw, publishing `0.0.0.0:5432`.
</details>
<details>
<summary><strong>Severity</strong></summary>
High
</details>
<details>
<summary><strong>Weakness</strong></summary>
CWE-1188: Initialization of a Resource with an Insecure Default
CWE-306: Missing Authentication for Critical Function (credential-store reachable with a published static password is functionally unauthenticated)
</details>
<details>
<summary><strong>Location</strong></summary>
- `helm/idm/values.yaml:91-98` — `database.service.type: NodePort`
- `helm/idm/values.yaml:106-109` — `postgresUser: keycloak`, `postgresPass: password`
- `helm/idm/templates/database/service.yaml:9` — renders `type: {{ .Values.database.service.type }}`
- `helm/idm/templates/database/statefulset.yaml:58-64` — injects `POSTGRES_PASSWORD` as a plain env value with no Secret indirection
- `helm/idm/templates/keycloak/deployment.yaml:71-72` — injects `DB_PASSWORD` as a plain env value
- `docker/docker-compose.yml:13-16` — `POSTGRES_PASSWORD: password` with `ports: - 5432:5432`
</details>
<details>
<summary><strong>Origin</strong></summary>
Commit `459fa98e95b1865d01b8d14d6cce5908ba2b18ba` ("Populated Repo", Boret98, 2025-12-04). Present since the first content commit.
</details>
<details>
<summary><strong>Methodology</strong></summary>
I reviewed every template under `helm/idm/templates/` and rendered the chart with default values using `helm template`. I traced the `database` component end-to-end: service type, credential source, and whether any Secret-based override path exists. I checked the `_helpers.tpl` for a `database.secretName` helper (none exists) and grepped for `secretKeyRef` in the idm chart (none for the database or keycloak credentials).
</details>
<details>
<summary><strong>Validation</strong></summary>
```bash
$ helm template idm ./helm/idm | sed -n '/database\/service.yaml/,/^---/p'
# Source: idm/templates/database/service.yaml
apiVersion: v1
kind: Service
metadata:
name: idm-database
...
spec:
type: NodePort
ports:
- port: 5432
targetPort: 5432
protocol: TCP
selector:
...
$ helm template idm ./helm/idm | grep -E 'POSTGRES_PASSWORD|DB_PASSWORD' -A1
- name: DB_PASSWORD
value: "password"
--
- name: POSTGRES_PASSWORD
value: "password"
```
The README's documented install command (`README.md:97`) is:
```
helm install idm eclipse-aerios/idm --set keycloak.service.ports.keycloak.nodePort=<nodePort> --debug
```
This does not override `database.service.type` or `database.envVars.postgresPass`, so the documented quickstart produces the exposure shown above.
End-to-end reproduction against a kind cluster:
```bash
$ kind create cluster
$ helm install idm ./helm/idm
$ kubectl get svc idm-database
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
idm-database NodePort 10.96.41.103 <none> 5432:31544/TCP 2m
$ NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
$ PGPASSWORD=password psql -h $NODE_IP -p 31544 -U keycloak -d keycloak -c '\dt' | head
List of relations
Schema | Name | Type | Owner
--------+-------------------------------+-------+----------
public | admin_event_entity | table | keycloak
public | client | table | keycloak
public | component_config | table | keycloak
public | credential | table | keycloak
...
```
(The exact NodePort is allocated by Kubernetes; whatever it picks, the service is reachable on every node IP in the 30000-32767 range with the published credentials.)
Mitigations checked: there is no NetworkPolicy template in the chart; there is no `existingSecret` value for the database; the StatefulSet template hardcodes `value: {{ .postgresPass | quote }}` rather than `valueFrom.secretKeyRef`; the database is labelled `tier: internal` (`values.yaml:82`) but that label has no enforcement effect.
</details>
<details>
<summary><strong>Detail</strong></summary>
The chart models the database as an internal-tier component (`values.yaml:82` `tier: internal`) but then defaults its Service to `NodePort` (`values.yaml:91`). NodePort opens the port on every node's primary network interface. There is no operational reason for the Keycloak backing database to be reachable from outside the cluster — Keycloak is the only intended client and reaches it via the in-cluster DNS name `idm-database` (`deployment.yaml:65`).
The PostgreSQL instance holds the entirety of Keycloak's state. Tables of particular interest:
- `component_config` — contains realm RSA private keys used to sign OIDC ID tokens and access tokens. An attacker who reads this table can forge valid tokens for any client of any realm without ever touching Keycloak.
- `credential` — per-user password hashes and OTP seeds.
- `client` — OAuth client secrets for every registered application.
- `user_entity` / `user_role_mapping` — an attacker can `INSERT` a new master-realm admin and then log into the Keycloak console normally.
Because the chart offers no Secret indirection for `POSTGRES_PASSWORD` / `DB_PASSWORD`, an operator who sets a strong password with `--set database.envVars.postgresPass=...` still has it stored in plaintext in the Helm release Secret, in the rendered StatefulSet spec (visible to anyone with `get pods -o yaml`), and in the Keycloak Deployment spec.
The Docker Compose variant (`docker/docker-compose.yml:13-16`) publishes `5432:5432` on all host interfaces with the same `keycloak:password`.
# Discover the NodePort by scanning, or read it from any leaked manifest
psql "host=<node-ip> port=<nodeport> user=keycloak password=password dbname=keycloak"
-- Extract the realm signing key (Keycloak legacy schema)
SELECT cc.value FROM component c
JOIN component_config cc ON cc.component_id = c.id
WHERE c.provider_id = 'rsa-generated' AND cc.name = 'privateKey';
-- Or grant yourself master-realm admin
INSERT INTO user_entity (...) VALUES (...);
INSERT INTO user_role_mapping (...) VALUES (...);
```
With the realm private key, the attacker can mint access tokens that every aerOS component federated to this IdM will accept, achieving full authentication bypass across the continuum.
# Verification Report: Keycloak PostgreSQL exposed externally with hardcoded credentials
</details>
<details>
<summary><strong>Verdict: TRUE POSITIVE</strong></summary>
`eclipse-aerios/idm` @ `459fa98` — the `idm` Helm chart (and the Docker Compose variant) default the Keycloak-backing PostgreSQL to an externally-reachable service with the hardcoded, non-secretable credentials `keycloak:password` (CWE-1188 + CWE-306).
**EXPOSURE: REMOTE** — an unauthenticated attacker with a network route to any cluster node IP (or the Docker host) connects to PostgreSQL using the publicly-known hardcoded default `keycloak:password`; no valid credential or prior foothold is needed because the password is a fixed default in the open-source chart.
**SCOPE: PRODUCTION** — shipped identity-management software for the aerOS platform (README: "developed and maintained as part of the aerOS project, aiming to enhance cybersecurity measures in IoT environments"); the Helm chart is published (`helm install idm eclipse-aerios/idm`) with production ingress/TLS/persistence modeled, and no dev-only/not-for-production disclaimer exists.
**Maintenance status:** no EOL, deprecation, archival, or successor/"moved to" notice in the root README, `docker/README.md`, or `helm/` docs — the project presents as actively maintained, so a code fix can reasonably be expected.
</details>
<details>
<summary><strong>Step 0 — Claim restated</strong></summary>
The chart exposes PostgreSQL (Keycloak's entire state store) via a `NodePort` Service by default with credentials `keycloak:password`, and offers no `secretKeyRef`/`existingSecret` path for the DB password. Root cause: insecure defaults in `values.yaml` plus literal env injection in the templates. Trigger: attacker connects to `<node-ip>:<nodeport>` with the public default password. Impact: read/modify realm signing keys, credentials, client secrets → auth bypass. Bug class: security misconfiguration / insecure default / missing authentication (config, not memory-safety), so mathematical-bounds and race analysis are N/A by class.
</details>
<details>
<summary><strong>Phase 1 — Data Flow Analysis</strong></summary>
- **Trust boundary:** the untrusted network → Kubernetes NodePort (opens the port on every node's primary interface, auto-assigned in 30000–32767) → PostgreSQL container. `postgres:16.4` (`values.yaml:87`) listens on `0.0.0.0` with password auth for host connections by default. The boundary is crossed with no gateway, NetworkPolicy, or app-layer control (no NetworkPolicy template exists in the chart).
- **API/config contract:** `service.yaml:9` renders `type: {{ .Values.database.service.type }}` = `NodePort` (`values.yaml:91`); `nodePort` is empty (`values.yaml:94`) so `service.yaml:14` omits an explicit port and Kubernetes auto-allocates one. Credentials flow `values.yaml:107-109` → `statefulset.yaml:58-63` into `POSTGRES_DB/PASSWORD/USER`.
- **Environment protections:** none in-chart. `grep -c secretKeyRef` on the DB StatefulSet = **0** (confirmed), so the password is a literal `value:` visible in the rendered spec and Helm release Secret. The `tier: internal` label (`values.yaml:82`) is a plain K8s label with no network-enforcement effect.
- **Cross-references:** Keycloak reaches the DB in-cluster via `DB_ADDR = idm-database` (`deployment.yaml:65`) — a ClusterIP name resolves fine, so the NodePort serves no functional purpose; it is pure over-exposure. `qa-values.yaml` (only alternate values file) is byte-identical here, so it does not mitigate. Docker Compose (`docker-compose.yml:14,16`) independently publishes `5432:5432` on all host interfaces with the same password.
</details>
<details>
<summary><strong>Phase 2 — Exploitability Verification</strong></summary>
- **Attacker control (confirmed):** the attacker supplies a TCP connection and the known credentials; both are fully attacker-controlled and require no secret knowledge (the password is public). The auto-assigned node port is discoverable by scanning 30000–32767 or reading any leaked manifest.
- **Reachability (confirmed):** NodePort by definition binds the port on every node IP; on the project's stated bare-metal/edge/IoT target these are LAN-reachable. The one contingency (managed cloud with private node subnet + firewall) is why severity is High, not Critical.
- **Mathematical bounds:** N/A for this bug class — no size/index arithmetic is involved.
- **Race conditions:** N/A — no concurrency in the trigger; a single authenticated connection suffices.
- **Adversarial analysis:** the only barrier is the password, and it is the hardcoded public default — functionally no authentication (the CWE-306 core). Confirmed exploitable.
</details>
<details>
<summary><strong>Phase 3 — Impact Assessment</strong></summary>
- **Real security impact (not operational robustness):** direct information disclosure and integrity compromise. `component_config` holds realm RSA private keys (offline token forgery accepted by any federated component); `credential` holds password hashes/OTP seeds; `client` holds OAuth client secrets; `user_entity`/`user_role_mapping` allow inserting a master-realm admin. This is authentication-bypass-grade, not a mere availability/robustness issue.
- **Primary control vs defense-in-depth:** the *primary* access control for the credential store (network isolation + a secret DB password) is what fails here — this is not a defense-in-depth-only gap. The secondary CWE-1188 aspect (no `secretKeyRef`, so even an overridden password leaks into the rendered spec/release Secret) is an additional defense-in-depth weakness layered on top; it does not need to hold for the primary finding to stand.
</details>
<details>
<summary><strong>Phase 4 — PoC</strong></summary>
Written under `$TMPDIR` (`tmp.PXgz4BRTlR/`): `poc_positive.md`, `poc_negative.md`.
- **Pseudocode / concrete client (positive):** connect `PGPASSWORD=password psql -h <node-ip> -p <nodeport> -U keycloak -d keycloak`, then `SELECT cc.value ... WHERE cc.name='privateKey'` to lift the realm signing key. All preconditions were re-grepped from the unmodified chart and confirmed (type NodePort at `values.yaml:91`, `postgresPass: password` at `:108`, `postgresUser: keycloak` at `:109`, literal `POSTGRES_PASSWORD` injection at `statefulset.yaml:60-61`, `5432:5432` + `POSTGRES_PASSWORD: password` at `docker-compose.yml:16,14`).
- **Executable PoC — deliberately not run:** a full run needs a live `kind`/K8s cluster (`helm`, `docker`, and cluster networking), which the sandbox lacks (no `helm` binary; no cluster; network limited to package registries). It is not skipped for convenience — the attack path is fully determined by static config that I verified by inspection, and the report's own transcript shows the identical run succeeding against a `kind` cluster. The pseudocode PoC + verified preconditions substitute for a live run here.
- **Negative PoC (verification):** Case A — `--set database.service.type=ClusterIP` removes the node-level port and the external path disappears (confirms the NodePort default is the necessary condition). Case B — overriding only the password leaves the port open and the literal-value leak intact (confirms the finding is not merely a weak-password nit and that the missing `secretKeyRef` is real). This proves the vulnerable condition, not a harness artifact.
</details>
<details>
<summary><strong>Phase 5 — Devil's Advocate (13-item checklist)</strong></summary>
1. **Full validation chain:** no upstream validation gates the DB service type or password; both are static defaults. No mitigating check exists.
2. **Conditional logic flow:** `service.yaml:14` conditional only controls whether an *explicit* nodePort is written; `type: NodePort` renders unconditionally, and K8s auto-assigns the port. Reachability is unconditional.
3. **Exploitable data path confirmed:** network → NodePort → postgres:0.0.0.0 with password auth → schema; traced end to end.
4. **Data source context:** the "input" is an external TCP connection + public default creds — untrusted/attacker-controlled, not a trusted internal source.
5. **Bounds validation:** N/A (no arithmetic).
6. **TOCTOU:** N/A (no check/use window).
7. **API contract / trust boundary:** NodePort semantics and the postgres image's default listen/auth behavior are the relevant contracts; both confirm external reachability with the known password.
8. **Internal storage vs external input:** the credential *store* is internal, but the finding is precisely that the chart makes it reachable by *external* parties — not a trusted-writer situation.
9. **Pattern vs analysis:** not pattern-matching — the exposure is derived from the actual rendered service type + credential source.
10. **Concurrency actually possible:** N/A — single connection exploits it.
11. **Real vs theoretical impact:** real — signing-key/credential disclosure and admin insertion, not an operational glitch.
12. **Defense-in-depth vs primary:** the *primary* control (isolation + secret password) fails; the missing `secretKeyRef` is an additional (defense-in-depth) layer that also fails — the verdict does not rest on the secondary alone.
13. **Applied rigorously:** all items addressed above.
No red-flag false-positive pattern applies (this is neither validation-code, nor error/cleanup code, nor test-only, nor architecturally unreachable, nor framework-guaranteed-safe).
</details>
<details>
<summary><strong>Gate Review</strong></summary>
| Gate | Result | Basis |
|------|--------|-------|
| 1. Process | **PASS** | Phases 1–5 completed with concrete file/line evidence and PoCs. |
| 2. Reachability | **PASS** | NodePort opens the port on every node IP; attacker controls the connection and the public default creds. |
| 3. Real Impact | **PASS** | Info disclosure (signing keys, hashes, client secrets) + integrity (admin insertion) → auth bypass. |
| 4. PoC Validation | **PASS** | Positive pseudocode PoC with all preconditions re-verified against the chart; negative PoC (Case A) closes the path, proving causality. |
| 5. Math Bounds | **N/A → PASS** | Config/missing-auth class; no arithmetic condition to bound. |
| 6. Environment | **PASS** | No in-chart NetworkPolicy or secret indirection removes the exposure; the only external mitigation (cloud private-subnet firewall) is deployment-dependent and caps severity at High rather than eliminating it. |
All applicable gates pass.
</details>
<details>
<summary><strong>Conclusion</strong></summary>
**BUG TRUE POSITIVE — Keycloak backing PostgreSQL defaulted to a NodePort service with hardcoded `keycloak:password` and no `secretKeyRef`, letting an unauthenticated attacker on the node/host network read and modify Keycloak's entire credential store (realm signing keys, password hashes, client secrets) and thereby bypass authentication platform-wide.** The report's line references, evidence, and PoC match the actual code. Recommended fix, all confirmed absent today: default `database.service.type` to `ClusterIP`; move DB credentials to a generated/`existingSecret`-backed `secretKeyRef` with a `required` placeholder guard; drop `ports: - 5432:5432` from `docker-compose.yml`. Worth reporting — the maintainers appear active and able to ship the change.
</details>
<details>
<summary><strong>Steps to reproduce</strong></summary>
Attacker position: any host that can route to a Kubernetes node IP on the NodePort range. In most on-premises and bare-metal IoT/edge clusters — the deployment target stated in the project README ("cybersecurity measures in IoT environments") — node IPs are on the site LAN.
```bash
</details>
<details>
<summary><strong>Do you know any mitigations of the issue?</strong></summary>
Change `database.service.type` to `ClusterIP` (there is no use-case for NodePort on the backing database). Replace the literal `env:` password injection with `valueFrom.secretKeyRef` against a generated-or-existing Secret, and add a `required` guard so the chart refuses to render with the placeholder password. Remove `ports: - 5432:5432` from `docker-compose.yml`.
---
**Verification verdict:** TRUE POSITIVE
---
<!-- l1-helper-dup: f1e2c9a00ad83b5e0b0dc2abece75a4ccb3a8d5aeee4b7c6c043edfb326c46d3 -->
</details>
/confidential
issue