Fix #213: KI-Vorschlag-Badge ohne innerHTML für Kontobezeichnungen (Stored XSS)

This commit is contained in:
fds2610-bot 2026-09-25 09:10:19 +02:00
parent f67e3b86f4
commit be0b38f64b
10 changed files with 241 additions and 31 deletions

View file

@ -2,6 +2,12 @@
---
## 2026-09-25 – Stored XSS im KI-Vorschlag-Badge behoben (#213)
- **`public/static/js/beleg-ki.js`:** `_showKiBadgeDone()` baut das Badge jetzt per `createElement`/`textContent` statt `innerHTML`. Kontobezeichnungen (aus `option.text`, also bereits dekodiert), Buchungstyp, USt-Satz und Lieferant landen nur noch als Text im DOM – eine Kontobezeichnung wie `Bank<img src=x onerror=…>` wird nicht mehr ausgeführt. Der „Neu generieren"-Button wird per `addEventListener` verdrahtet, die Optik liegt in neuen `.ki-badge-*`-Klassen in `app.css`. KI-Werte werden außerdem nicht mehr in CSS-Selektoren eingesetzt (Option/Radio werden per Wertvergleich gesucht).
- **Weitere `innerHTML`-Stellen mit Server- bzw. Fremddaten:** `setupAccountSearch()` in `app.js` (Kontobezeichnung), Upload-Status auf Dashboard und Belegliste (Server-Fehlermeldungen), Dateiname im Bankauszug-Upload (`bank/show.php`) und Ollama-Fehlerdetails in den Einstellungen setzen die Werte jetzt als Text.
- **`tests/102_KiBadgeXssTest.php`** (neu) und **`tests/e2e/specs/beleg-ki-badge.spec.js`** (neu): prüfen, dass das Badge keine Serverdaten mehr per `innerHTML` einfügt bzw. dass ein Schadcode-Payload im Browser nur als Text erscheint.
## 2026-09-20 – Analyse prüft das Importskript (#209)
- **`scripts/analyse-nextcloud.sh`:** neuer Abschnitt 6 prüft, ob `scripts/container/nextcloud-import.php` im Container existiert und syntaktisch ladbar ist (`php -l`, ohne Ausführung), und nennt den Pfad, den der Host-Cronjob verwenden muss. Fehlt das Skript, endet die Analyse mit Exit 1 – auch bei erfolgreicher Verbindung. Ein `--dry-run`-Schalter für den Import wurde nicht ergänzt.

View file

@ -155,6 +155,14 @@ img { max-width: 100%; }
.flash-warning { background: #fef9c3; color: #854d0e; border: 1px solid #fef08a; }
.flash-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
/* KI-Vorschlag-Badge auf der Belegdetailseite (beleg-ki.js) */
.ki-badge-done { justify-content: space-between; align-items: flex-start; gap: 1rem; }
.ki-badge-details { font-size: .9em; }
.ki-badge-zeile { font-size: .85em; }
.ki-badge-hinweis { color: var(--color-muted); font-style: italic; }
.ki-badge-warn { color: var(--color-warning); font-weight: 600; }
.ki-badge-btn { flex-shrink: 0; margin-top: .1rem; }
/* ── Cards ──────────────────────────────────────────────────── */
.card {
background: var(--color-surface);

View file

@ -315,7 +315,13 @@ function setupAccountSearch(inputId, hiddenId) {
items.forEach(item => {
const li = document.createElement('li');
li.style.cssText = 'padding:.5rem .75rem; cursor:pointer; font-size:.875rem; border-bottom:1px solid var(--color-border)';
li.innerHTML = `<strong>${item.kontonummer}</strong> – ${item.bezeichnung} <small style="color:var(--color-muted)">(${item.kontenart})</small>`;
// Kontodaten nur als Text einfügen (Fix #213)
const nr = document.createElement('strong');
nr.textContent = item.kontonummer;
const art = document.createElement('small');
art.className = 'text-muted';
art.textContent = `(${item.kontenart})`;
li.append(nr, ` – ${item.bezeichnung} `, art);
li.addEventListener('mousedown', () => {
input.value = `${item.kontonummer} – ${item.bezeichnung}`;
hidden.value = item.id;

View file

@ -13,8 +13,19 @@ function _selectById(selectEl, id) {
}
}
// Option per Wert suchen – ohne den (KI-gelieferten) Wert in einen CSS-Selektor einzusetzen
function _findOption(selectEl, id) {
if (!selectEl) return null;
const str = String(id);
for (const opt of selectEl.options) {
if (opt.value === str) return opt;
}
return null;
}
function _applyKiVorschlag(v) {
const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]');
const btRadio = Array.from(document.querySelectorAll('input[name="buchungstyp"]'))
.find(r => r.value === String(v.buchungstyp));
if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); }
const ustEl = document.getElementById('ust_satz_select');
if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); }
@ -49,46 +60,62 @@ function _applyKiVorschlag(v) {
}
}
// Kleiner Helfer: Element mit Klasse und Textinhalt (niemals HTML) erzeugen
function _kiEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = String(text);
return el;
}
function _showKiBadgeDone(arr) {
// arr ist immer ein Array (bereits normalisiert)
// arr ist immer ein Array (bereits normalisiert).
// Alle Werte stammen aus DB/KI bzw. aus Kontobezeichnungen und werden
// ausschließlich per textContent eingefügt (Fix #213: Stored XSS).
const v = arr[0];
const katEl = document.getElementById('kategorie_konto_id');
const zhlEl = document.getElementById('zahlungskonto_id');
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
const zhlOpt = zhlEl ? zhlEl.querySelector('option[value="' + v.zahlungskonto_id + '"]') : null;
const katOpt = _findOption(document.getElementById('kategorie_konto_id'), v.kategorie_konto_id);
const zhlOpt = _findOption(document.getElementById('zahlungskonto_id'), v.zahlungskonto_id);
const katTxt = katOpt ? katOpt.text : v.kategorie_konto_id;
const zhlTxt = zhlOpt ? zhlOpt.text : v.zahlungskonto_id;
const supTxt = v.supplier_name
? ' · Lieferant: ' + _esc(v.supplier_name) + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
: '';
// Erste Zeile (bereits ins Formular übertragen)
let details =
const details = _kiEl('span', 'ki-badge-details',
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
'Kategorie: ' + katTxt + ' · ' +
'Zahlung: ' + zhlTxt + supTxt;
'Zahlung: ' + zhlTxt);
if (v.supplier_name) {
details.append(' · Lieferant: ' + v.supplier_name);
if (v.supplier_found === false) {
details.append(' ', _kiEl('span', 'ki-badge-warn', '(neu – bitte prüfen)'));
}
}
const inhalt = document.createElement('div');
inhalt.append(_kiEl('strong', '', 'KI-Vorschlag:'), ' ', details);
// Alle Buchungszeilen mit Beträgen anzeigen
const fmt = n => parseFloat(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
let zeilen = '';
arr.forEach((z, i) => {
const satz = parseFloat(z.ust_satz) || 0;
const netto = parseFloat(z.betrag_netto) || 0;
const ust = Math.round(netto * z.ust_satz / 100 * 100) / 100;
const ust = Math.round(netto * satz / 100 * 100) / 100;
const brutto = Math.round((netto + ust) * 100) / 100;
const hinweis = (i > 0) ? ' <span style="color:var(--color-muted);font-style:italic">(bitte als weitere Buchung erfassen)</span>' : '';
zeilen += '<br><span style="font-size:.85em">Zeile ' + (i + 1) + ': '
+ 'Netto ' + fmt(netto) + ' € | USt ' + z.ust_satz + '% ' + fmt(ust) + ' € | Brutto ' + fmt(brutto) + ' €'
+ hinweis + '</span>';
const zeile = _kiEl('span', 'ki-badge-zeile',
'Zeile ' + (i + 1) + ': '
+ 'Netto ' + fmt(netto) + ' € | USt ' + satz + '% ' + fmt(ust) + ' € | Brutto ' + fmt(brutto) + ' €');
if (i > 0) zeile.append(' ', _kiEl('span', 'ki-badge-hinweis', '(bitte als weitere Buchung erfassen)'));
inhalt.append(document.createElement('br'), zeile);
});
const neuBtn = _kiAktiv
? '<button class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-top:.1rem" onclick="_startKiAnalyse()">Neu generieren</button>'
: '';
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem">' +
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span>' + zeilen + '</div>' +
neuBtn +
'</div>';
const box = _kiEl('div', 'flash flash-info ki-badge-done');
box.append(inhalt);
if (_kiAktiv) {
const neuBtn = _kiEl('button', 'btn btn-secondary btn-sm ki-badge-btn', 'Neu generieren');
neuBtn.type = 'button';
neuBtn.addEventListener('click', _startKiAnalyse);
box.append(neuBtn);
}
document.getElementById('ki-badge').replaceChildren(box);
}
function _showKiBadgeNoResult() {

View file

@ -184,8 +184,10 @@ $restFormatted = number_format($remaining, 2, ',', '.');
btn.disabled = true;
return;
}
status.innerHTML = '<span style="color:var(--color-success)">✓ ' + file.name
+ ' (' + (file.size / 1024).toFixed(1) + ' KB)</span>';
const ok = document.createElement('span');
ok.style.color = 'var(--color-success)';
ok.textContent = '✓ ' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
status.replaceChildren(ok);
btn.disabled = false;
}

View file

@ -33,7 +33,11 @@
}
function setStatus(msg, color) {
status.innerHTML = `<span style="color:${color || 'var(--color-muted)'}">${msg}</span>`;
// msg kann Server-Fehlertexte enthalten → nur als Text einfügen (Fix #213)
const span = document.createElement('span');
span.style.color = color || 'var(--color-muted)';
span.textContent = msg;
status.replaceChildren(span);
}
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });

View file

@ -105,7 +105,11 @@ $detail = function (string $bucket, ?int $month = null) use ($stats): string {
}
function setStatus(msg, color) {
status.innerHTML = `<span style="color:${color || 'var(--color-muted)'}">${msg}</span>`;
// msg kann Server-Fehlertexte enthalten → nur als Text einfügen (Fix #213)
const span = document.createElement('span');
span.style.color = color || 'var(--color-muted)';
span.textContent = msg;
status.replaceChildren(span);
}
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });

View file

@ -548,7 +548,11 @@ document.getElementById('btn-llm-test')?.addEventListener('click', function () {
if (data.elapsed) lines.push('Nach ' + data.elapsed + ' ms');
lines.push('Tipp: sudo ufw allow from 172.16.0.0/12 to any port 11434 (oder firewall prüfen)');
lines.push('Logs: docker compose logs web');
detail.innerHTML = lines.map(function (l) { return '<div>' + l + '</div>'; }).join('');
detail.replaceChildren.apply(detail, lines.map(function (l) {
var d = document.createElement('div');
d.textContent = l;
return d;
}));
detail.style.display = 'block';
detail.style.color = '#dc2626';
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* Test 102: Kein Stored XSS über Kontobezeichnungen im KI-Vorschlag-Badge (Issue #213)
*
* Das Badge wird im Browser aus Kontobezeichnungen (option.text, bereits
* dekodiert) und KI-Daten gebaut. Der Test stellt sicher, dass diese Werte
* nicht mehr per innerHTML eingefügt werden. Das Verhalten im Browser prüft
* tests/e2e/specs/beleg-ki-badge.spec.js.
*/
class KiBadgeXssTest extends TestCase
{
protected string $testName = 'Test 102: KI-Badge ohne innerHTML für Serverdaten';
public function run(): void
{
$root = __DIR__ . '/..';
$js = (string)file_get_contents($root . '/public/static/js/beleg-ki.js');
$body = $this->functionBody($js, '_showKiBadgeDone');
$this->assertNotEmpty($body, '_showKiBadgeDone() in beleg-ki.js gefunden');
$this->assert(!str_contains($body, 'innerHTML'), '_showKiBadgeDone() setzt kein innerHTML');
$this->assert(!str_contains($body, 'onclick'), '_showKiBadgeDone() erzeugt keinen Inline-Handler');
$this->assert(str_contains($body, 'textContent'), '_showKiBadgeDone() fügt Werte per textContent ein');
$this->assert(
!preg_match('/querySelector\([^)]*\+\s*v\./', $js),
'KI-Werte werden nicht in CSS-Selektoren eingesetzt'
);
$app = (string)file_get_contents($root . '/public/static/js/app.js');
$this->assert(
!str_contains($app, '${item.bezeichnung} <small'),
'setupAccountSearch() fügt die Kontobezeichnung nicht per innerHTML ein'
);
foreach (['src/Views/dashboard.php', 'src/Views/belege/list.php'] as $view) {
$content = (string)file_get_contents($root . '/' . $view);
$this->assert(!str_contains($content, '}">${msg}</span>'), "$view: Statusmeldung nicht per innerHTML");
}
$settings = (string)file_get_contents($root . '/src/Views/settings/form.php');
$this->assert(
!str_contains($settings, "'<div>' + l + '</div>'"),
'settings/form.php: Ollama-Fehlerdetails nicht per innerHTML'
);
}
/** Liefert den Rumpf einer JS-Funktion (bis zur passenden schließenden Klammer). */
private function functionBody(string $js, string $name): string
{
$pos = strpos($js, 'function ' . $name . '(');
if ($pos === false) {
return '';
}
$start = strpos($js, '{', $pos);
if ($start === false) {
return '';
}
$depth = 0;
$len = strlen($js);
for ($i = $start; $i < $len; $i++) {
if ($js[$i] === '{') {
$depth++;
} elseif ($js[$i] === '}') {
$depth--;
if ($depth === 0) {
return substr($js, $start, $i - $start + 1);
}
}
}
return '';
}
}

View file

@ -0,0 +1,74 @@
// @ts-check
// Issue #213: Stored XSS über Kontobezeichnungen im KI-Vorschlag-Badge.
//
// Das Badge liest die Kontobezeichnung aus den <option>-Elementen (option.text
// ist bereits dekodiert) und baut daraus die Anzeige. Einen gespeicherten
// KI-Vorschlag kann der Test ohne Ollama nicht erzeugen, und bestehende Konten
// darf er nicht umbenennen. Er legt deshalb im Browser eine Option mit
// Schadcode-Bezeichnung an und ruft _showKiBadgeDone() direkt auf.
const { test, expect } = require('@playwright/test')
const { uploadBeleg, cleanupBeleg } = require('../helpers/app')
const PAYLOAD = '<img src=x onerror="window.__xss213=1">'
test.describe('KI-Vorschlag-Badge', () => {
let belegId = 0
test.afterEach(async ({ page }) => {
await cleanupBeleg(page, belegId)
belegId = 0
})
test('Kontobezeichnungen und KI-Werte werden nur als Text angezeigt', async ({ page }) => {
const { id } = await uploadBeleg(page)
belegId = id
// Ist die KI-Sektion in dieser Instanz aus, fehlen Badge und Skript:
// dann beides nachladen (ohne Vorschlag, ohne automatische Analyse).
if (await page.locator('#ki-badge').count() === 0) {
await page.evaluate(() => {
const el = document.createElement('div')
el.id = 'ki-badge'
el.dataset.belegId = '0'
el.dataset.kiAktiv = 'false'
el.dataset.kiVorschlag = '[]'
document.querySelector('main, body').appendChild(el)
})
await page.addScriptTag({ url: '/static/js/beleg-ki.js' })
}
await page.evaluate(payload => {
for (const [selId, value] of [['kategorie_konto_id', '999001'], ['zahlungskonto_id', '999002']]) {
const opt = document.createElement('option')
opt.value = value
opt.textContent = 'Bank' + payload
document.getElementById(selId).appendChild(opt)
}
// eslint-disable-next-line no-undef
_showKiBadgeDone([{
buchungstyp: 'Ausgabe' + payload,
kategorie_konto_id: '999001',
zahlungskonto_id: '999002',
supplier_name: 'Lieferant' + payload,
supplier_found: false,
betrag_netto: '100',
ust_satz: '19' + payload,
}, { betrag_netto: '10', ust_satz: 7 }])
}, PAYLOAD)
const badge = page.locator('#ki-badge')
await expect(badge).toContainText('KI-Vorschlag:')
await expect(badge).toContainText('Kategorie: Bank' + PAYLOAD)
await expect(badge).toContainText('Zahlung: Bank' + PAYLOAD)
await expect(badge).toContainText('Lieferant: Lieferant' + PAYLOAD)
await expect(badge).toContainText('(neu – bitte prüfen)')
await expect(badge).toContainText('Zeile 1: Netto 100,00 € | USt 19% 19,00 € | Brutto 119,00 €')
await expect(badge).toContainText('Zeile 2: Netto 10,00 € | USt 7% 0,70 € | Brutto 10,70 €')
await expect(badge).toContainText('(bitte als weitere Buchung erfassen)')
await expect(badge.locator('img')).toHaveCount(0)
await page.waitForTimeout(300)
expect(await page.evaluate(() => /** @type {any} */ (window).__xss213)).toBeUndefined()
})
})