Files
Kontrollverlust/web/js/feed.js
T
irrlicht 0089bc5909 feed.js: HTML-Injection in linkify schließen (escapeHtml deckt nun Quotes ab)
escapeHtml nutzte den textContent->innerHTML-Trick, der laut HTML-Spec nur
& < > escaped, aber keine Anführungszeichen (in Textknoten harmlos). linkify
steckt den Wert anschließend in ein href="..."-Attribut, wo ein rohes " das
Attribut vorzeitig schließt und beliebige Attribute (on...=, style=) am <a>
einschleusbar macht -- HTML-Injection. Die CSP (default-src 'self', kein
'unsafe-inline') verhinderte nur die Ausführung, nicht die Injektion.

Ersetzt durch einen spec-korrekten Entity-Escaper, der auch " und ' abdeckt,
sodass der Wert in Text- UND Attributkontext sicher ist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:52:18 +02:00

184 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const api = {
async get(url) {
const r = await fetch(url, { credentials: "same-origin" });
return r.json();
},
async post(url, formData) {
const r = await fetch(url, {
method: "POST",
body: formData,
credentials: "same-origin",
});
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
},
};
// Escaped für Text- UND Attributkontext. Der frühere textContent->innerHTML-
// Trick ließ Anführungszeichen stehen (in Textknoten harmlos); linkify steckt
// den Wert aber in ein href="..."-Attribut, wo ein " ausbrechen würde.
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
function avatarUrl(path) {
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
}
function linkify(s) {
const escaped = escapeHtml(s);
const url = /https?:\/\/[^\s<]+/g;
return escaped
.replace(url, (m) => `<a href="${m}" target="_blank" rel="noopener">${m}</a>`)
.replace(/\n/g, "<br>");
}
function renderEntry(e) {
const el = document.createElement("article");
el.className = "card";
const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
const replyLabel =
e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`;
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
el.innerHTML = `
<div class="muted byline"><span>[deleted] · ${when} · <a href="/e/${e.pid}">${replyLabel}</a></span></div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">[deleted]</div>
</div>
</div>
`;
el.addEventListener("click", (ev) => {
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
});
return el;
}
const user = encodeURIComponent(e.username);
let media = "";
if (e.filepath) {
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
}
el.innerHTML = `
<div class="muted byline">
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when} · <a href="/e/${e.pid}">${replyLabel}</a></span>
</div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">${linkify(e.content)}</div>
${media}
<div class="interactions">
<div class="row">
<button class="ghost" data-mode="left">Links</button>
<span class="muted"> / </span>
<button class="ghost" data-mode="right">Rechts</button>
</div>
</div>
</div>
</div>
`;
setupVoting(el, e.pid);
el.addEventListener("click", (ev) => {
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
});
return el;
}
function setupVoting(el, pid) {
const box = el.querySelector(".row");
const counts = box.querySelector("span");
const buttons = box.querySelectorAll(".ghost");
function render(state) {
if (state.error) {
counts.textContent = state.error;
return;
}
counts.textContent = `${state.left} / ${state.right}`;
buttons.forEach((b) =>
b.classList.toggle("selected", b.dataset.mode === state.selected)
);
}
buttons.forEach((b) =>
b.addEventListener("click", async () => {
const fd = new FormData();
fd.append("mode", b.dataset.mode);
const res = await api.post(`/entry/${pid}/vote`, fd);
render(res.data);
})
);
api.get(`/entry/${pid}/votes`).then(render);
}
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor) {
let page = 0;
let loading = false;
let done = false;
async function loadMore() {
if (loading || done) return;
loading = true;
const entries = await api.get(urlFor(page));
if (!entries.length) {
done = true;
const end = document.createElement("p");
end.className = "muted";
end.textContent = "YOU REACHED THE END!";
feedEl.appendChild(end);
} else {
entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
page++;
}
loading = false;
// Der IntersectionObserver feuert nur bei Zustandswechseln. Füllt die
// gerade geladene Seite den Viewport nicht über den Sentinel hinaus, gäbe
// es keinen Wechsel und nie ein Nachladen -> hier aktiv weiterladen, bis
// der Sentinel aus dem Sichtbereich wandert oder das Ende erreicht ist.
if (!done && sentinelVisible()) loadMore();
}
function sentinelVisible() {
return sentinelEl.getBoundingClientRect().top <= window.innerHeight;
}
function reset() {
page = 0;
done = false;
feedEl.innerHTML = "";
loadMore();
}
// rootMargin lädt schon kurz vor Erreichen des Sentinels nach (flüssiger und
// robust gegen den Fall eines 0-hohen Sentinels exakt am Dokumentende).
const observer = new IntersectionObserver(
(items) => {
if (items.some((i) => i.isIntersecting)) loadMore();
},
{ rootMargin: "300px" }
);
observer.observe(sentinelEl);
loadMore();
return { reset };
}