## Summary

OpenRemote 1.25.0 (and, per the advisory, all releases through 1.26.1) exposes `POST /api/{realm}/console/register` as an anonymous console-registration endpoint. The vulnerable implementation interprets a client-supplied `id` as both an object reference and sufficient authority to update that object. An unauthenticated caller who knows an existing `ConsoleAsset` ID can therefore load that asset globally, replace its console metadata and provider map—including the push-notification token—and persist the change without proving authentication, ownership, or realm membership. Current-runtime testing through the real OpenRemote HTTP service confirmed the flaw twice and showed OpenRemote 1.26.2 rejecting the same procedure twice with HTTP 403.

## Impact

- **Affected component:** OpenRemote Manager console registration API, specifically `ConsoleResourceImpl.register()` behind `POST /api/{realm}/console/register`.
- **Affected versions:** Advisory range `<= 1.26.1`; the runtime proof uses the vendor `openremote/manager:1.25.0` image (source commit `0ed159ba423de28896ab77a07427cdc32ed0712a`).
- **Fixed version:** 1.26.2, vendor image source commit `87845b5d9bccc74ba144e5879994780b92f69089`.
- **Risk:** Critical authorization bypass / insecure direct object reference (CWE-639, also consistent with CWE-862). Network access and knowledge of a victim console asset ID are sufficient; no bearer token or user session is needed.
- **Consequences:** Persistent replacement of console name, version, platform, and provider data. In particular, replacing the push token can redirect console notifications to attacker-controlled routing data or deny delivery by installing an invalid token. The reproduced victims were created by a real authenticated owner and retained one owner link while anonymous requests modified their state.

## Impact Parity

- **Disclosed/claimed maximum impact:** Unauthenticated remote authorization bypass allowing overwrite of an existing console's push token and metadata.
- **Reproduced impact:** Two independent unauthenticated API requests on OpenRemote 1.25.0 returned HTTP 200 for existing, owner-linked console IDs and persisted attacker-selected console names and push tokens. SQL evidence confirms each asset still had one owner link. The same procedure against 1.26.2 returned HTTP 403 and preserved original values in two attempts.
- **Parity:** `full`
- **Not demonstrated:** The proof intentionally does not contact Firebase or send a real push notification. It proves the persistent routing-token overwrite that enables notification redirection or denial, not delivery through a third-party push provider. No broader account login bypass or code execution was claimed or attempted.

## Root Cause

The endpoint is deliberately public so a new console can register anonymously. In 1.25.0, however, `ConsoleResourceImpl.register()` also accepts a caller-supplied `id` for updates:

1. Lines 83–90 read the submitted ID and perform `assetStorageService.find(id, true)`, which globally loads the existing asset.
2. The only check is that the object is a `ConsoleAsset`; there is no authentication, ownership-link, or realm equality check.
3. Lines 106–125 copy attacker-controlled name, provider map, version, and platform into that existing asset.
4. Lines 128–130 call `assetStorageService.merge(consoleAsset)`, persisting the changes.
5. Ownership handling at lines 133–140 is guarded by `isAuthenticated()` and therefore does not restrict the anonymous update path.

The public API declaration itself says an ID means “update” while also stating that the endpoint allows anonymous registration. The implementation failed to distinguish anonymous creation from mutation of existing server-side state. Possession of an object identifier was incorrectly treated as write authority.

OpenRemote 1.26.2 fixes this in `ConsoleResourceImpl` by tracking `existingConsole`, requiring the loaded asset realm to match, rejecting anonymous existing-console updates with HTTP 403, and requiring an authenticated user's existing asset link before mutation. It also rejects a supplied ID that does not exist. The fixed release commit is [`87845b5d9bccc74ba144e5879994780b92f69089`](https://github.com/openremote/openremote/commit/87845b5d9bccc74ba144e5879994780b92f69089).

## Reproduction Steps

1. Run `bundle/repro/reproduction_steps.sh` from any directory (optionally set `PRUVA_ROOT` to the bundle path).
2. The script:
   - Resolves the prepared project cache from `bundle/project_cache_context.json` and verifies the 1.25.0 and 1.26.2 source commits.
   - Installs its required Java/PostgreSQL/PostGIS/TimescaleDB/OCI tools.
   - Downloads and unpacks exact vendor OCI image bytes for OpenRemote Manager 1.25.0, OpenRemote Manager 1.26.2, and OpenRemote Keycloak 23.0.7.6.
   - Starts real Keycloak, PostgreSQL, and OpenRemote Manager services.
   - Creates victim consoles through the real API using an authenticated owner token, recording their owner links.
   - Sends no-`Authorization` registration requests containing each known victim ID and attacker-selected names/tokens.
   - Runs two vulnerable attempts and requires HTTP 200 plus persisted attacker values.
   - Starts the fixed release against the same runtime stack, runs two controls, and requires HTTP 403 plus unchanged persisted values.
   - Writes `bundle/repro/runtime_manifest.json` on every attempt.
3. Success ends with:

   ```text
   VULNERABLE_ATTEMPT_1_CONFIRMED: anonymous known-ID request overwrote owner-linked console metadata and push token.
   VULNERABLE_ATTEMPT_2_CONFIRMED: anonymous known-ID request overwrote owner-linked console metadata and push token.
   FIXED_ATTEMPT_1_CONFIRMED: HTTP 403 and original owner-linked console state retained.
   FIXED_ATTEMPT_2_CONFIRMED: HTTP 403 and original owner-linked console state retained.
   REPRODUCTION_SUCCESS: CVE-2026-66013 confirmed through the real OpenRemote HTTP API with repeated fixed-version negative controls.
   ```

## Evidence

- `bundle/logs/reproduction_steps.log` — orchestration and final per-attempt verdict markers.
- `bundle/logs/keycloak-service.log` — real OpenRemote Keycloak startup and database-backed identity service.
- `bundle/logs/vulnerable-attempt-1-service.log` — OpenRemote 1.25.0 service startup and both vulnerable requests. It includes `Starting OpenRemote version: v1.25.0` and asset-update events showing attacker-selected values.
- `bundle/logs/fixed-attempt-1-service.log` — OpenRemote 1.26.2 startup and both fixed controls. It includes `Starting OpenRemote version: v1.26.2`, HTTP 403, and `Anonymous requests cannot update existing console registrations`.
- `bundle/artifacts/openremote-console-cve-2026-66013/image-identities.json` — source commits and SHA-256 identities of executed manager JARs and Keycloak runtime.
- `bundle/artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-{1,2}/attacker-overwrite.request.txt` — exact anonymous HTTP request shape; explicitly records `Authorization: <ABSENT>`.
- `bundle/artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-{1,2}/attacker-overwrite.response.{headers,json}` — HTTP 200 and response containing the known victim ID and attacker values.
- `bundle/artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-{1,2}/persisted-state.json` — database-backed proof of attacker name/token and `owner_link_count: 1`.
- `bundle/artifacts/openremote-console-cve-2026-66013/fixed-attempt-{1,2}/attacker-overwrite.response.{headers,json}` — HTTP 403 and the fixed exception message.
- `bundle/artifacts/openremote-console-cve-2026-66013/fixed-attempt-{1,2}/persisted-state.json` — original victim name/token remain, with one owner link.
- `bundle/repro/runtime_manifest.json` — strict runtime evidence manifest for the real endpoint path.

Representative persisted vulnerable evidence has this form:

```json
{"id":"<known-victim-id>","console_name":"ATTACKER_NAME_VULNERABLE_1","push_token":"ATTACKER_TOKEN_VULNERABLE_1","owner_link_count":1}
```

Representative fixed evidence has this form:

```json
{"id":"<known-victim-id>","console_name":"VICTIM_FIXED_1","push_token":"VICTIM_TOKEN_FIXED_1","owner_link_count":1}
```

The primary proof uses no sanitizer and crosses the actual HTTP endpoint boundary into the real OpenRemote Manager product.

## Recommendations / Next Steps

- Upgrade to OpenRemote 1.26.2 or later.
- Never allow anonymous registration to mutate an existing ID. Reject existing IDs for public requests or require a server-issued, device-bound registration secret.
- For authenticated updates, enforce all of: matching request/object realm, current ownership link, and explicit mutation permission.
- Keep anonymous creation and existing-console update as distinct authorization paths.
- Add regression tests for anonymous existing-ID mutation, cross-realm IDs, another user's ID, nonexistent client-supplied IDs, and legitimate owner re-registration.
- Consider rotating affected push tokens and reviewing console registration history where IDs may have been exposed.

## Additional Notes

- The reproduction script was run successfully in consecutive executions; it resets isolated runtime state and uses fresh victim asset IDs and markers each run.
- Each execution performs two vulnerable requests and two fixed controls.
- The script uses the vendor's exact manager and Keycloak OCI bytes, unpacked with `skopeo`/`umoci` because the worker's rootless Docker storage quota cannot hold the complete multi-service stack. The application entrypoint and HTTP behavior remain the real product, not a parser or mocked service.
- Ubuntu's Apache-licensed TimescaleDB package lacks the optional HyperCore columnstore feature required by an unrelated datapoint migration. The script records that migration as applied after it reaches that specific compatibility error. Console assets, user links, registration, provider data, and the vulnerable endpoint do not use HyperCore; all relevant schema migrations and production code execute normally.
- The known asset ID remains the principal practical precondition. The script obtains it from the authenticated victim creation response, then uses only that ID in the anonymous attack request.
