Root cause
Injection & unsafe execution introduced
Add Logs viewer page with connection status indicators
How AI contributed: Direct introductionPublished Mar 15, 2026 in
openvessl/anchorrAll 2 findings in this repository
Anchorr is a Discord bot for requesting movies and TV shows and receiving notifications when items are added to a media server. In versions 1.4.1 and below, a stored Cross-site Scripting (XSS) vulnerability in the web dashboard's User Mapping dropdown allows any unprivileged Discord user in the configured guild to execute arbitrary JavaScript in the Anchorr admin's browser. By chaining this with the GET /api/config endpoint (which returns all secrets in plaintext), an attacker can exfiltrate every credential stored in Anchorr which includes DISCORD_TOKEN, JELLYFIN_API_KEY, JELLYSEERR_API_KEY, JWT_SECRET, WEBHOOK_SECRET, and bcrypt password hashes without any authentication to Anchorr itself. This issue has been fixed in version 1.4.2.
A Claude commit whose subject is unrelated (logs viewer) can still be the first introduction of the named XSS sink; match the hunk, not the subject. Recover 404 global GHSAs from the first-party repo advisory.
Root cause
Add Logs viewer page with connection status indicators
Fix
Rebuilds Discord-member options and selected-user displays with DOM APIs and textContent, and assigns avatar src only after URL validation, eliminating the stored-markup injection path.
Case facts
+ try {+ const response = await fetch("/api/discord-members");+ const data = await response.json();+ + if (data.success && data.members) {+ discordMembers = data.members;+ membersLoaded = true;+ populateDiscordMemberSelect();+ } else {+ const customSelect = document.getElementById("discord-user-select");+ if (customSelect) {+ const trigger = customSelect.querySelector(".custom-select-trigger");+ if (trigger) {+ trigger.placeholder = "Error loading members. Is bot running?";+ }+ }+ console.error("Failed to load Discord members:", data.message);+ }+ } catch (error) {+ console.error("Exception loading Discord members:", error);+ const customSelect = document.getElementById("discord-user-select");+ if (customSelect) {+ const trigger = customSelect.querySelector(".custom-select-trigger");+ if (trigger) {+ trigger.placeholder = "Error loading members";+ }+ }+ }+ }++ function populateDiscordMemberSelect() {+ const customSelect = document.getElementById("discord-user-select");+ if (!customSelect) return;++ const optionsContainer = customSelect.querySelector(".custom-select-options");+ if (!optionsContainer) return;++ optionsContainer.innerHTML = '';+ + discordMembers.forEach(member => {+ const option = document.createElement("div");+ option.className = "custom-select-option";+ option.dataset.value = member.id;+ option.dataset.displayName = member.displayName;+ option.dataset.username = member.username;+ + // Check if this member is already in active mappings+ const isInMapping = currentMappings.some(mapping => mapping.discordUserId === member.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 = `+ <img src="${member.avatar}" alt="${member.displayName}">+ <div class="custom-select-option-text">+ <div class="custom-select-option-name">${member.displayName}</div>+ <div class="custom-select-option-username">@${member.username}</div>+ </div>+ ${checkmarkHtml}+ `;+ + option.addEventListener("click", () => {+ selectDiscordUser(member);+ });+ + optionsContainer.appendChild(option);+ });+ }++ function selectDiscordUser(member) {+ const customSelect = document.getElementById("discord-user-select");+ const trigger = customSelect.querySelector(".custom-select-trigger");+ + // Store selected value+ customSelect.dataset.value = member.id;+ customSelect.dataset.displayName = member.displayName;+ customSelect.dataset.username = member.username;+ + // 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 = document.createElement("div");+ display.className = "custom-select-display";+ customSelect.insertBefore(display, customSelect.querySelector(".custom-select-dropdown"));+ }+ + display.innerHTML = `@@ -2011,18 +2011,33 @@ document.addEventListener("DOMContentLoaded", async () => { const isInMapping = currentMappings.some( (mapping) => mapping.discordUserId === member.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 = `- <img src="${member.avatar}" alt="${escapeHtml(member.displayName)}">- <div class="custom-select-option-text">- <div class="custom-select-option-name">${escapeHtml(member.displayName)}</div>- <div class="custom-select-option-username">@${escapeHtml(member.username)}</div>- </div>- ${checkmarkHtml}- `;+ // Build option content safely via DOM APIs (no avatar URL interpolation into innerHTML)+ const textContainer = document.createElement("div");+ textContainer.className = "custom-select-option-text";+ const nameDiv = document.createElement("div");+ nameDiv.className = "custom-select-option-name";+ nameDiv.textContent = member.displayName;+ const usernameDiv = document.createElement("div");+ usernameDiv.className = "custom-select-option-username";+ usernameDiv.textContent = "@" + member.username;+ textContainer.appendChild(nameDiv);+ textContainer.appendChild(usernameDiv);+ const avatarImg = document.createElement("img");+ avatarImg.alt = member.displayName || "";+ if (isSafeAvatarUrl(member.avatar)) {+ avatarImg.src = member.avatar;+ }+ option.appendChild(avatarImg);+ option.appendChild(textContainer);+ if (isInMapping) {+ const checkIcon = document.createElement("i");+ checkIcon.className = "bi bi-check-circle-fill";+ checkIcon.style.color = "var(--green)";+ checkIcon.style.marginLeft = "auto";+ checkIcon.style.fontSize = "1.1rem";+ option.appendChild(checkIcon);+ } option.addEventListener("click", () => { selectDiscordUser(member);@@ -2055,10 +2070,19 @@ document.addEventListener("DOMContentLoaded", async () => { ); } - display.innerHTML = `- <img src="${member.avatar}" alt="${escapeHtml(member.displayName)}">- <span>${escapeHtml(member.displayName)} (@${escapeHtml(member.username)})</span>- `;+ // Safely build selected display using DOM APIs+ while (display.firstChild) {+ display.removeChild(display.firstChild);+ }+ const img = document.createElement("img");+ img.alt = member.displayName || "";+ if (isSafeAvatarUrl(member.avatar)) {+ img.src = member.avatar;+ }+ const span = document.createElement("span");+ span.textContent = `${member.displayName} (@${member.username})`;+ display.appendChild(img);+ display.appendChild(span); // Force display to be visible immediately display.style.display = "flex";@@ -2357,8 +2381,9 @@ document.addEventListener("DOMContentLoaded", async () => { avatarUrl = discordMember?.avatar; } - const avatarHtml = avatarUrl- ? `<img src="${avatarUrl}" style="width: 42px; height: 42px; border-radius: 50%; margin-right: 0.75rem; flex-shrink: 0;" alt="${escapeHtml(discordName)}">`+ const safeAvatarUrl = isSafeAvatarUrl(avatarUrl) ? avatarUrl : null;+ const avatarHtml = safeAvatarUrl+ ? `<img src="${safeAvatarUrl}" style="width: 42px; height: 42px; border-radius: 50%; margin-right: 0.75rem; flex-shrink: 0;" alt="${escapeHtml(discordName)}">` : ""; return `@@ -2646,13 +2671,25 @@ document.addEventListener("DOMContentLoaded", async () => { if (selectedValue) { const member = discordMembers.find((m) => m.id === selectedValue); if (member) {- trigger.innerHTML = `- <div class="custom-select-trigger-content">- <img src="${member.avatar}" alt="${escapeHtml(member.displayName)}">- <span>${escapeHtml(member.displayName)} (@${escapeHtml(member.username)})</span>- </div>AI-assisted change 28b68eb490a70d71ceac43dcfa44332b744691c463b1e8845c4339408421f0d4 · Fix 26e9a2301d9199b366ac914764f6de0888b493b5f25d23384ec30fc4da65428f