Rekey 2.2.1 closes the Next.js image optimizer RCE. Rebuild today
Blog ReleaseRekey 2.2.1 is a security release. The panel, portal and admin app ran Next.js 15.5.18, which carries two critical advisories in the image optimizer: a malformed AVIF or SVG file could run code on the server that decodes it. 2.2.1 moves those apps to Next.js 15.5.28 and sharp 0.35.5, the API to Fastify 5.12.7, and every Docker image to Node.js 24.22.1. There is no migration and nothing changes in your integration. Rebuild and redeploy today, in any order you like.
What a crafted image could do
Every Next.js app serves a route called /_next/image. It takes a url, a width and a quality, fetches the file, hands it to the sharp library, and returns a resized WebP or AVIF. Sharp decodes AVIF with libheif and SVG with librsvg, both native C libraries. Two memory bugs in those decoders, a heap overflow in libheif and a use-after-free in librsvg, meant a file built to trip them could execute code inside the Node process on a glibc-based Linux host. The node:24-slim image every Rekey container starts from is Debian, so it is exactly that kind of host.
The advisories are GHSA-2xp9-vwfh-vxw4 (AVIF, patched in 15.5.24) and GHSA-2464-fhr4-w9xw (SVG, patched in 15.5.28) on the Next.js side, both scored 9.5 out of 10, and GHSA-rgj7-g3m4-5g8c, GHSA-wq5f-xc86-pv6w and GHSA-f88m-g3jw-g9cj on the sharp side. Sharp 0.35.5 bundles the fixed libheif and librsvg, and the repository pins every copy of sharp at that floor.
Was the route reachable in your deployment
The route was live. We could not find a way in on a stock deployment, which is not the same as there being none. This is what the three apps do.
- None of the panel, the portal or the admin app sets an
imagesblock in its Next.js config, so the optimizer is on with its defaults, and each app's middleware skips/_next/image. Nothing of Rekey's own sign-in sits in front of the route. An unauthenticated request reaches it. - None of the three apps renders a
next/imagecomponent. Next.js registers the route whether or not an app calls it. - With no
remotePatterns, Next.js refuses anyurlthat is not a path on the app's own origin: it answers 400 with"url" parameter is not allowed. An attacker cannot point the optimizer at a file they host. - Without
dangerouslyAllowSVG, which none of the apps set, an SVG is refused after the fetch and never reaches librsvg. - That leaves an AVIF served by the app itself. The panel and the admin app ship fonts and one PNG logo in
public/; the portal ships no public directory at all. We found no AVIF for a request to name.
So the vulnerable decoder was in every image, behind a public route, with no file we know of to feed it. That is a thin margin to rely on, and reasoning like this is how people stay on a vulnerable build for a year. Treat it as critical and upgrade.
The marketing site is different: it renders screenshots through next/image, so the optimizer is in daily use there. It moved to 15.5.28 in the same release.
The other advisories in the release
2.2.1 also picks up every other advisory open against the three layers. Most do not apply to how Rekey uses the library, and the table says which.
| Advisory | What it is, and whether Rekey was exposed |
|---|---|
GHSA-p293-qw3h-jr36 | Next.js. Unauthenticated code execution through path traversal on Windows-hosted servers, patched in 15.5.24. Rekey's images are Linux. It matters only if you run the panel with next start on Windows yourself. |
GHSA-m99w-x7hq-7vfj, GHSA-p3m5-59mp-jv5r | Next.js. Crafted requests to an App Router app with a Server Action spin the CPU and block other requests in the process. The panel, portal and admin app all use Server Actions, so a denial of service was possible. Patched in 15.5.21 and 15.5.28. |
GHSA-p9j2-gv94-2wf4 | Next.js. Server-side request forgery when a rewrites() or redirects() rule builds a hostname from the request. No Rekey app has a rewrite or redirect rule, so this did not apply. |
GHSA-89xv-2m56-2m9x | Next.js. A Server Action that forwards or redirects can be sent to a host chosen through the Host header when the host is not pinned upstream. Standalone output has handled the host since 14.2, and the production compose runs behind a proxy. Pin Host and X-Forwarded-Host at your edge regardless. |
GHSA-667r-xxjv-c9mm | Fastify. An $async request schema could replace the validated body with a nested value. The API has no $async schema. |
GHSA-p68q-wchp-6fh7 | Fastify. A malformed URL reached the not-found handler without its hooks. The API's not-found handler returns a fixed ROUTE_NOT_FOUND body and no data, so nothing leaked. |
GHSA-hwr6-493r-vm6h, GHSA-9q9j-q6p8-xq58 | Fastify. A schema set to false was not enforced, and header rules written with the dependencies keyword were skipped. The API uses neither pattern. |
One Fastify change needed code. Fastify 5.12 made a numeric trustProxy trust nobody, so the old setting of 1 would have given every client the proxy's address and put every per-IP limit and lockout in one shared bucket. The API now passes a function that trusts exactly one hop, which is what 1 used to mean. Your TRUSTED_PROXIES and proxy secrets are unchanged.
The rest is dependency floors: Node.js 24.22.1 in every image, undici 7.30, @fastify/swagger-ui 6, and the @modelcontextprotocol/sdk 1.32 inside @rekey.dev/mcp. The full list is in the 2.2.1 changelog.
How to upgrade a self-hosted deployment
The production compose file builds the images from source, so an upgrade is a checkout and a rebuild. No migration runs, nothing is written to the database, and no environment variable is new, so the order of the services does not matter this time. With the bundled docker-compose.prod.yml:
git fetch --tags git checkout v2.2.1 docker compose -f docker-compose.prod.yml build --pull docker compose -f docker-compose.prod.yml up -d
--pull matters: it fetches the new node:24.22.1-slim base rather than reusing the cached one. Then confirm which build is answering:
curl -fsS https://api.example.com/health/ready # "version":"2.2.1"
- If you run the admin app, rebuild it with
docker build --target=admin-runtime .and restart it. It is not in the compose file. - If you deploy the API, panel and portal as separate units, rebuild each unit from the
v2.2.1tag. Each unit carries its own copy of the vulnerable layers, so a half-upgraded stack is still exposed on the unit you skipped. - Upgrade the
@rekey.dev/*packages in your own apps to 2.2.1 when you next deploy them. They share the release number, and@rekey.dev/mcpis the one with a patched dependency inside it.
Rekey Cloud
If you are still on 2.1, the 2.2.0 upgrade guide comes first; 2.2.1 is a patch on top of it.
Also in 2.2.1: a token can create a waitlist or an onboarding question
2.2.1 also ships one feature, for anyone scripting an Application's setup instead of clicking through the panel. Until now the profile-field routes and the list routes accepted a panel session only, so a deploy script or an agent could not create a waitlist or define onboarding questions. They now accept an operator personal access token as well:
curl -X POST https://api.example.com/api/v1/tenant/applications/$APP_ID/lists \
-H "Authorization: Bearer rp_op_..." \
-H "Content-Type: application/json" \
-d '{ "key": "waitlist", "name": "Waitlist" }'The routes that take a token are the profile schema (GET and PUT /api/v1/tenant/applications/:id/profile-schema), an end user's profile answers, onboarding complete and skip, and list create, update, archive, restore, members, submissions and unsubscribe. The token needs the route's scope in its own scopes: read for reads and applications:write for writes. That holds even when the operator who minted it is an owner, so a read-only token an owner pasted into a script stays read-only. A list's CSV export is the exception: it stays session-only, because a long-lived token that can pull every address off a list is a different kind of secret.
The same over MCP
The operator MCP server gained twelve tools for the same work: get_profile_schema, set_profile_schema, update_end_user_profile, complete_onboarding, skip_onboarding, create_contact_list, update_contact_list, archive_contact_list, restore_contact_list, list_list_members, list_list_submissions and unsubscribe_list_member. The write tools need write access on the connection and the OWNER or ADMIN workspace role, as every operator write tool does.
Two read tools carry the same role floor: list_list_members and list_list_submissions return email addresses, so over MCP they need OWNER or ADMIN whatever grant a member holds on the Application. A member with an audience grant can still see those rows in the panel; what changes is that an agent they connected cannot pull the addresses into its context. MCP tool errors now also carry the API error's details, so when set_profile_schema is refused the agent sees which field or option the refusal is about.
How to connect a client, and the scope rules in full, are on the MCP page. The changelog has the complete list for 2.2.1.
Was this page helpful?
