Root cause
Injection & unsafe execution introduced
Add Logs viewer page with connection status indicators
How AI contributed: Direct introductionPublished Mar 20, 2026 in
openvessl/anchorrAll 2 findings in this repository
Stored XSS (CWE-80) -> privilege escalation Jellyseerr user to Anchorr admin: the Jellyseerr user selector on the admin dashboard Mappings tab (web/script.js populateJellyseerrUserSelect) renders every Jellyseerr user's displayName and email into innerHTML templates with no escaping (option.innerHTML with ${user.displayName} / ${user.email}, and the trigger display innerHTML at ~line 2161); the escapeHtml() helper exists in the same file (line ~2935) but is not applied here. Any Jellyseerr account holder can set their display name/email to an <img src=x onerror=...> payload; when an Anchorr admin opens the Mappings tab the payload runs in the admin's authenticated session, calls GET /api/config (app.js), which returns the full decoded config incl. JWT_SECRET, DISCORD_TOKEN, JELLYFIN_API_KEY, JELLYSEERR_API_KEY, WEBHOOK_SECRET; the attacker forges an admin JWT (utils/auth.js signs with JWT_SECRET) for full dashboard takeover and compromise of Jellyfin/Jellyseerr/Discord. CVSS 9.0 critical; affects <= 1.4.1, patched 1.4.2. Reported by xdnewlun1 and Rex50527 via HackerOne.
Root cause
Add Logs viewer page with connection status indicators
Fix
Escapes Jellyseerr users' display names and email addresses before inserting them into dropdown and selected-user HTML, so stored profile text cannot create executable markup in the admin page.
Case facts
+ }+ });+ + // Close dropdown and reset input+ customSelect.classList.remove("active");+ trigger.value = "";+ trigger.setAttribute("readonly", "");+ }++ async function loadJellyseerrUsers() {+ if (usersLoaded && jellyseerrUsers.length > 0) {+ return;+ }+ + try {+ const response = await fetch("/api/jellyseerr-users");+ const data = await response.json();+ + if (data.success && data.users) {+ jellyseerrUsers = data.users;+ usersLoaded = true;+ populateJellyseerrUserSelect();+ } else {+ console.error("Failed to load Jellyseerr users:", data.message);+ }+ } catch (error) {+ console.error("Error loading Jellyseerr users:", error);+ }+ }++ function populateJellyseerrUserSelect() {+ const customSelect = document.getElementById("jellyseerr-user-select");+ if (!customSelect) return;++ const optionsContainer = customSelect.querySelector(".custom-select-options");+ if (!optionsContainer) return;++ optionsContainer.innerHTML = '';+ + jellyseerrUsers.forEach(user => {+ const option = document.createElement("div");+ option.className = "custom-select-option";+ option.dataset.value = user.id;+ option.dataset.displayName = user.displayName;+ option.dataset.email = user.email || '';+ option.dataset.avatar = user.avatar || '';+ + const avatarHtml = user.avatar + ? `<img src="${user.avatar}" alt="${user.displayName}">`+ : `<div style="width: 36px; height: 36px; border-radius: 50%; background: var(--surface1); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--mauve);">${user.displayName.charAt(0).toUpperCase()}</div>`;+ + // Check if this user is already in active mappings+ const isInMapping = currentMappings.some(mapping => String(mapping.jellyseerrUserId) === String(user.id));+ const checkmarkHtml = isInMapping + ? `<i class="bi bi-check-circle-fill" style="color: var(--green); margin-left: auto; font-size: 1.1rem;"></i>`+ : '';+ + option.innerHTML = `+ ${avatarHtml}+ <div class="custom-select-option-text">+ <div class="custom-select-option-name">${user.displayName}</div>+ ${user.email ? `<div class="custom-select-option-username">${user.email}</div>` : ''}+ </div>+ ${checkmarkHtml}+ `;+ + option.addEventListener("click", () => {+ selectJellyseerrUser(user);+ });+ + optionsContainer.appendChild(option);+ });+ }++ function selectJellyseerrUser(user) {+ const customSelect = document.getElementById("jellyseerr-user-select");+ const trigger = customSelect.querySelector(".custom-select-trigger");+ + // Store selected value+ customSelect.dataset.value = user.id;+ customSelect.dataset.displayName = user.displayName;+ customSelect.dataset.email = user.email || '';+ customSelect.dataset.avatar = user.avatar || '';+ + // Add has-selection class to hide input+ customSelect.classList.add("has-selection");+ + // Create or update display element+ let display = customSelect.querySelector(".custom-select-display");+ if (!display) { } display.innerHTML = `- <img src="${member.avatar}" alt="${member.displayName}">- <span>${member.displayName} (@${member.username})</span>+ <img src="${member.avatar}" alt="${escapeHtml(member.displayName)}">+ <span>${escapeHtml(member.displayName)} (@${escapeHtml(member.username)})</span> `; // Force display to be visible immediately@@ -2108,8 +2119,8 @@ document.addEventListener("DOMContentLoaded", async () => { option.dataset.avatar = user.avatar || ""; const avatarHtml = user.avatar- ? `<img src="${user.avatar}" alt="${user.displayName}">`- : `<div style="width: 36px; height: 36px; border-radius: 50%; background: var(--surface1); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--mauve);">${user.displayName+ ? `<img src="${user.avatar}" alt="${escapeHtml(user.displayName)}">`+ : `<div style="width: 36px; height: 36px; border-radius: 50%; background: var(--surface1); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--mauve);">${escapeHtml(user.displayName) .charAt(0) .toUpperCase()}</div>`; @@ -2124,10 +2135,10 @@ document.addEventListener("DOMContentLoaded", async () => { option.innerHTML = ` ${avatarHtml} <div class="custom-select-option-text">- <div class="custom-select-option-name">${user.displayName}</div>+ <div class="custom-select-option-name">${escapeHtml(user.displayName)}</div> ${ user.email- ? `<div class="custom-select-option-username">${user.email}</div>`+ ? `<div class="custom-select-option-username">${escapeHtml(user.email)}</div>` : "" } </div>@@ -2167,14 +2178,14 @@ document.addEventListener("DOMContentLoaded", async () => { } const avatarHtml = user.avatar- ? `<img src="${user.avatar}" alt="${user.displayName}">`- : `<div style="width: 32px; height: 32px; border-radius: 50%; background: var(--surface1); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--mauve); flex-shrink: 0;">${user.displayName+ ? `<img src="${user.avatar}" alt="${escapeHtml(user.displayName)}">`+ : `<div style="width: 32px; height: 32px; border-radius: 50%; background: var(--surface1); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--mauve); flex-shrink: 0;">${escapeHtml(user.displayName) .charAt(0) .toUpperCase()}</div>`; display.innerHTML = ` ${avatarHtml}- <span>${user.displayName}${user.email ? ` (${user.email})` : ""}</span>+ <span>${escapeHtml(user.displayName)}${user.email ? ` (${escapeHtml(user.email)})` : ""}</span> `; // Force display to be visible immediately@@ -2321,7 +2332,7 @@ document.addEventListener("DOMContentLoaded", async () => { } const avatarHtml = avatarUrl- ? `<img src="${avatarUrl}" style="width: 42px; height: 42px; border-radius: 50%; margin-right: 0.75rem; flex-shrink: 0;" alt="${discordName}">`+ ? `<img src="${avatarUrl}" style="width: 42px; height: 42px; border-radius: 50%; margin-right: 0.75rem; flex-shrink: 0;" alt="${escapeHtml(discordName)}">` : ""; return `@@ -2611,8 +2622,8 @@ document.addEventListener("DOMContentLoaded", async () => { if (member) { trigger.innerHTML = ` <div class="custom-select-trigger-content">- <img src="${member.avatar}" alt="${member.displayName}">- <span>${member.displayName} (@${member.username})</span>+ <img src="${member.avatar}" alt="${escapeHtml(member.displayName)}">+ <span>${escapeHtml(member.displayName)} (@${escapeHtml(member.username)})</span> </div> <i class="bi bi-chevron-down"></i> `;@@ -2713,8 +2724,8 @@ document.addEventListener("DOMContentLoaded", async () => { if (user) { trigger.innerHTML = ` <div class="custom-select-trigger-content">- <span>${user.displayName}${- user.email ? ` (${user.email})` : ""+ <span>${escapeHtml(user.displayName)}${+ user.email ? ` (${escapeHtml(user.email)})` : "" }</span> </div> <i class="bi bi-chevron-down"></i>@@ -2817,7 +2828,7 @@ document.addEventListener("DOMContentLoaded", async () => { value="${role.id}" ${isChecked ? "checked" : ""}> <div class="role-color-indicator" style="background-color: ${roleColor};"></div>- <span class="role-name">${role.name}</span>+ <span class="role-name">${escapeHtml(role.name)}</span> <span class="role-member-count">${AI-assisted change 0863b7e953ce59c9db9660812f02c44b9bc27b096b698ef1c0d953238b4f00ab · Fix 73244a96c88b41f3502980d0e242dbdb12d7d8dd7e65dd687205a4b89ca62cad