Escape rendered suggestion content and validate URLs

This commit is contained in:
2026-02-05 16:51:05 +01:00
parent d88469724a
commit 1d28ea6568
4 changed files with 76 additions and 26 deletions

View File

@@ -164,6 +164,13 @@ internal static class EndpointHelpers
return data[8..].StartsWith(brandBytes); return data[8..].StartsWith(brandBytes);
} }
public static bool IsValidHttpUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return true; // empty is allowed
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
return uri.Scheme is "http" or "https";
}
public static async Task<bool> IsAdmin(HttpContext ctx, AppDbContext db, IConfiguration config) public static async Task<bool> IsAdmin(HttpContext ctx, AppDbContext db, IConfiguration config)
{ {
var player = await GetAuthenticatedPlayer(ctx, db); var player = await GetAuthenticatedPlayer(ctx, db);

View File

@@ -57,6 +57,10 @@ public static class SuggestEndpoints
{ {
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." }); return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." });
} }
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
return Results.BadRequest(new { error = "Game URL must be http or https." });
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
return Results.BadRequest(new { error = "YouTube URL must be http or https." });
if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError)) if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError))
return Results.BadRequest(new { error = playersError }); return Results.BadRequest(new { error = playersError });
@@ -160,6 +164,10 @@ public static class SuggestEndpoints
{ {
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." }); return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." });
} }
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
return Results.BadRequest(new { error = "Game URL must be http or https." });
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
return Results.BadRequest(new { error = "YouTube URL must be http or https." });
if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError)) if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError))
return Results.BadRequest(new { error = playersError }); return Results.BadRequest(new { error = playersError });

View File

@@ -1,7 +1,7 @@
# TASKS - Review findings # TASKS - Review findings
- [x] **Critical - Unsigned auth cookie allows impersonation** (`Infrastructure/PlayerIdentityExtensions.cs`, `Endpoints/EndpointHelpers.cs`, `wwwroot/js/api.js`): The `player` cookie stores a raw GUID and is trusted on every request; `/api/suggestions/all` exposes other players' IDs, so a user can copy an ID, set the cookie, and become that account (including admins). Move to ASP.NET Core cookie auth with signed/encrypted tickets or a server-side session table, and avoid exposing player IDs where not necessary. - [x] **Critical - Unsigned auth cookie allows impersonation** (`Infrastructure/PlayerIdentityExtensions.cs`, `Endpoints/EndpointHelpers.cs`, `wwwroot/js/api.js`): The `player` cookie stores a raw GUID and is trusted on every request; `/api/suggestions/all` exposes other players' IDs, so a user can copy an ID, set the cookie, and become that account (including admins). Move to ASP.NET Core cookie auth with signed/encrypted tickets or a server-side session table, and avoid exposing player IDs where not necessary.
- [ ] **Critical - Stored XSS via suggestion content/links** (`wwwroot/js/ui.js`, `wwwroot/index.html`, `Endpoints/SuggestEndpoints.cs`): Names, descriptions, and URLs are injected with `innerHTML` and unescaped links; `gameUrl`/`youtubeUrl` accept any scheme. A malicious suggestion can run script when viewed. Sanitize/escape on render (use `textContent`), validate URLs to http/https on the server, and consider HTML sanitization for descriptions. - [x] **Critical - Stored XSS via suggestion content/links** (`wwwroot/js/ui.js`, `wwwroot/index.html`, `Endpoints/SuggestEndpoints.cs`): Names, descriptions, and URLs are injected with `innerHTML` and unescaped links; `gameUrl`/`youtubeUrl` accept any scheme. A malicious suggestion can run script when viewed. Sanitize/escape on render (use `textContent`), validate URLs to http/https on the server, and consider HTML sanitization for descriptions.
- [ ] **High - SSRF surface in screenshot validation** (`Endpoints/EndpointHelpers.IsReachableImageAsync`, `SuggestEndpoints.cs`): The server performs HEAD/GET requests to arbitrary user-provided URLs with minimal limits, enabling internal port probing and latency spikes. Restrict to allowlisted hosts or disable remote fetch; at minimum block private IPs/localhost, disallow redirects, and enforce tight size/time limits. - [ ] **High - SSRF surface in screenshot validation** (`Endpoints/EndpointHelpers.IsReachableImageAsync`, `SuggestEndpoints.cs`): The server performs HEAD/GET requests to arbitrary user-provided URLs with minimal limits, enabling internal port probing and latency spikes. Restrict to allowlisted hosts or disable remote fetch; at minimum block private IPs/localhost, disallow redirects, and enforce tight size/time limits.
- [ ] **High - Phase recalculation writes on every poll** (`Endpoints/EndpointHelpers.GetPhase`, `wwwroot/app.js`): `GetPhase` unconditionally calls `SaveChangesAsync`; the client polls `/api/state` every 4s, causing constant writes and SQLite WAL churn/locks even when nothing changes. Track a `changed` flag and persist only when state mutations occur; consider reducing poll frequency or using long polling/server push later. - [ ] **High - Phase recalculation writes on every poll** (`Endpoints/EndpointHelpers.GetPhase`, `wwwroot/app.js`): `GetPhase` unconditionally calls `SaveChangesAsync`; the client polls `/api/state` every 4s, causing constant writes and SQLite WAL churn/locks even when nothing changes. Track a `changed` flag and persist only when state mutations occur; consider reducing poll frequency or using long polling/server push later.
- [ ] **Medium - Admin key accepted via query string** (`Endpoints/EndpointHelpers.IsAdmin`): Accepting `key` in the query leaks secrets via logs and referrers. Require the `X-Admin-Key` header (or authenticated admin account) only, and rate-limit/monitor attempts. - [ ] **Medium - Admin key accepted via query string** (`Endpoints/EndpointHelpers.IsAdmin`): Accepting `key` in the query leaks secrets via logs and referrers. Require the `X-Admin-Key` header (or authenticated admin account) only, and rate-limit/monitor attempts.

View File

@@ -13,6 +13,25 @@ const truncate = (text, max) => {
if (!text) return ""; if (!text) return "";
return text.length > max ? `${text.slice(0, max - 1)}` : text; return text.length > max ? `${text.slice(0, max - 1)}` : text;
}; };
const escapeHtml = (value) =>
(value ?? "")
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const safeUrl = (url) => {
if (!url) return null;
try {
const u = new URL(url);
if (u.protocol === "http:" || u.protocol === "https:") return u.href;
} catch {
return null;
}
return null;
};
const cssEscapeUrl = (url) => url.replace(/['")\\]/g, "\\$&");
export function setAuthUI(isAuthed) { export function setAuthUI(isAuthed) {
const main = document.querySelector("main"); const main = document.querySelector("main");
@@ -264,25 +283,30 @@ export function renderResults() {
const row = document.createElement("tr"); const row = document.createElement("tr");
const podiumClass = rank === 1 ? "podium podium-1" : rank === 2 ? "podium podium-2" : rank === 3 ? "podium podium-3" : ""; const podiumClass = rank === 1 ? "podium podium-1" : rank === 2 ? "podium podium-2" : rank === 3 ? "podium podium-3" : "";
row.className = podiumClass; row.className = podiumClass;
const safeName = escapeHtml(r.name);
const safeAuthor = escapeHtml(r.author ?? "—");
const safeShot = safeUrl(r.screenshotUrl);
const safeGameUrl = safeUrl(r.gameUrl);
const safeYoutubeUrl = safeUrl(r.youtubeUrl);
row.innerHTML = ` row.innerHTML = `
<td class="rank-cell"><span class="medal">${medal}</span></td> <td class="rank-cell"><span class="medal">${medal}</span></td>
<td class="game-cell"> <td class="game-cell">
${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''} ${safeShot ? `<img class="thumb clickable-thumb" src="${safeShot}" alt="${safeName}">` : ''}
<div class="game-meta"> <div class="game-meta">
<div class="title-line"> <div class="title-line">
<span class="title-text">${r.name}</span> <span class="title-text">${safeName}</span>
${renderLinkBadge(r)} ${renderLinkBadge(r)}
</div> </div>
${buildResultMeta(r)} ${buildResultMeta(r)}
</div> </div>
</td> </td>
<td class="author-cell">${r.author ?? "—"}</td> <td class="author-cell">${safeAuthor || "—"}</td>
<td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td> <td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td>
<td>${formatVotes(r.votes)}</td> <td>${formatVotes(r.votes)}</td>
<td>${formatMyVote(r.myVote)}</td> <td>${formatMyVote(r.myVote)}</td>
<td> <td>
${r.gameUrl ? `<a class="link compact" href="${r.gameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ''} ${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ''}
${r.youtubeUrl ? `<a class="link compact" href="${r.youtubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ''} ${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ''}
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
@@ -301,7 +325,7 @@ function buildResultMeta(r) {
const players = hasPlayers const players = hasPlayers
? t("card.players", { min: r.minPlayers ?? "?", max: r.maxPlayers ?? "?" }) ? t("card.players", { min: r.minPlayers ?? "?", max: r.maxPlayers ?? "?" })
: null; : null;
const bits = [r.genre, players].filter(Boolean); const bits = [r.genre ? escapeHtml(r.genre) : null, players].filter(Boolean);
if (bits.length === 0) return ""; if (bits.length === 0) return "";
return `<div class="muted small">${bits.join(" • ")}</div>`; return `<div class="muted small">${bits.join(" • ")}</div>`;
} }
@@ -324,6 +348,13 @@ export function buildCard(
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "game-card"; card.className = "game-card";
const hasImage = !!s.screenshotUrl; const hasImage = !!s.screenshotUrl;
const safeShot = safeUrl(s.screenshotUrl);
const nameText = escapeHtml(s.name);
const genreText = escapeHtml(s.genre);
const descText = escapeHtml(s.description);
const authorText = escapeHtml(s.author);
const safeGameUrl = safeUrl(s.gameUrl);
const safeYoutubeUrl = safeUrl(s.youtubeUrl);
const linkedTitles = linkedPeerTitles(s); const linkedTitles = linkedPeerTitles(s);
const linked = isLinked(s); const linked = isLinked(s);
const linkTooltip = linked const linkTooltip = linked
@@ -331,11 +362,12 @@ export function buildCard(
? t("card.linkedWith", { names: linkedTitles.join(", ") }) ? t("card.linkedWith", { names: linkedTitles.join(", ") })
: t("card.linked") : t("card.linked")
: ""; : "";
const linkTooltipSafe = escapeHtml(linkTooltip);
const linkChip = linked const linkChip = linked
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltip}">🔗</button>` ? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltipSafe}">🔗</button>`
: ""; : "";
const visual = hasImage const visual = hasImage && safeShot
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>` ? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
: `<div class="card-visual"></div>`; : `<div class="card-visual"></div>`;
const hasPlayers = s.minPlayers || s.maxPlayers; const hasPlayers = s.minPlayers || s.maxPlayers;
const players = hasPlayers const players = hasPlayers
@@ -346,36 +378,36 @@ export function buildCard(
: ""; : "";
const genreAndPlayers = s.genre const genreAndPlayers = s.genre
? hasPlayers ? hasPlayers
? `${s.genre}${players}` ? `${genreText}${players}`
: s.genre : genreText
: hasPlayers : hasPlayers
? players ? players
: undefined; : undefined;
const hasExtraInfo = genreAndPlayers || s.gameUrl || s.youtubeUrl; const hasExtraInfo = genreAndPlayers || safeGameUrl || safeYoutubeUrl;
card.innerHTML = ` card.innerHTML = `
${visual} ${visual}
<div class="card-body"> <div class="card-body">
<div class="card-title-row"> <div class="card-title-row">
<h3 class="card-title" title="${s.name}">${s.name}</h3> <h3 class="card-title" title="${nameText}">${nameText}</h3>
<div class="title-meta"> <div class="title-meta">
${linkChip} ${linkChip}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""} ${showAuthor && s.author ? `<span class="chip">${authorText}</span>` : ""}
${allowEdit ? `<button class="chip icon" data-edit="${s.id}" type="button" title="${t("card.edit")}">✏️</button>` : ""} ${allowEdit ? `<button class="chip icon" data-edit="${s.id}" type="button" title="${t("card.edit")}">✏️</button>` : ""}
${allowDelete ? `<button class="chip icon danger-chip" data-delete="${s.id}" type="button" title="${t("card.delete")}">✕</button>` : ""} ${allowDelete ? `<button class="chip icon danger-chip" data-delete="${s.id}" type="button" title="${t("card.delete")}">✕</button>` : ""}
</div> </div>
</div> </div>
${hasExtraInfo ? `<p class="muted">` : ""} ${hasExtraInfo ? `<p class="muted">` : ""}
${genreAndPlayers ? genreAndPlayers : ""} ${genreAndPlayers ? genreAndPlayers : ""}
${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""} ${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""}
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""} ${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""}
${hasExtraInfo ? `</p>` : ""} ${hasExtraInfo ? `</p>` : ""}
${s.description ? `<p>${s.description}</p>` : ""} ${s.description ? `<p>${descText}</p>` : ""}
</div> </div>
`; `;
if (hasImage) { if (hasImage) {
const btn = card.querySelector(".card-visual"); const btn = card.querySelector(".card-visual");
setupCardVisualHover(btn, s.screenshotUrl); setupCardVisualHover(btn, safeShot);
btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name)); btn.addEventListener("click", () => openLightbox(safeShot, s.name));
} }
if (linked && state.me?.isAdmin) { if (linked && state.me?.isAdmin) {
const unlinkBtn = card.querySelector("[data-unlink]"); const unlinkBtn = card.querySelector("[data-unlink]");
@@ -587,11 +619,12 @@ export function openNewSuggestionModal() {
export function openLightbox(url, title) { export function openLightbox(url, title) {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "lightbox"; overlay.className = "lightbox";
const safeTitle = escapeHtml(title || "");
overlay.innerHTML = ` overlay.innerHTML = `
<div class="lightbox-content"> <div class="lightbox-content">
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button> <button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
<img src="${url}" alt="${title}" /> <img src="${url}" alt="${safeTitle}" />
<p>${title || ""}</p> <p>${safeTitle}</p>
</div> </div>
`; `;
overlay.addEventListener("click", (e) => { overlay.addEventListener("click", (e) => {
@@ -728,10 +761,12 @@ function renderAdminVoteStatus() {
state.adminVoteStatus.voters.forEach((v) => { state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const statusText = displayPlayerStatus(v); const statusText = displayPlayerStatus(v);
const gamesTooltip = (v.suggestionTitles || []).join(", "); const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = ` tr.innerHTML = `
<td title="${v.name}">${truncate(v.name, 28)}</td> <td title="${escapeHtml(v.name)}">${nameText}</td>
<td class="muted small" title="${v.username}">${truncate(v.username, 24)}</td> <td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${statusText}</td> <td>${statusText}</td>
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td> <td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td> <td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
@@ -930,7 +965,7 @@ function linkTooltip(s) {
function renderLinkBadge(s) { function renderLinkBadge(s) {
if (!isLinked(s)) return ""; if (!isLinked(s)) return "";
return `<span class="chip icon link-chip" title="${linkTooltip(s)}">🔗</span>`; return `<span class="chip icon link-chip" title="${escapeHtml(linkTooltip(s))}">🔗</span>`;
} }
function buildLinkOptionLabel(s) { function buildLinkOptionLabel(s) {