Escape rendered suggestion content and validate URLs
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
2
TASKS.md
2
TASKS.md
@@ -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.
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user