[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

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