Härtung (Code-Review): persistiertes Session-Secret statt Default-Fallback; timing-safe API-Token-Vergleich; Secure-Cookie auf HTTPS; HTML-Sanitizer für richtext/html-Blöcke + Seiten-Body (Stored-XSS); 14 Unit-Tests (Rabatt/MwSt/Versand/Sanitizer) + npm test

This commit is contained in:
2026-06-18 07:00:31 +00:00
parent 67b2fb78b7
commit fc2ad9e678
8 changed files with 81 additions and 12 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hd-commerce",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hd-commerce",
"version": "2.1.0",
"version": "2.2.0",
"dependencies": {
"@astrojs/node": "^9.1.3",
"@fontsource-variable/fraunces": "^5.1.0",
+3 -2
View File
@@ -8,7 +8,8 @@
"dev": "astro dev",
"build": "astro build",
"start": "node ./dist/server/entry.mjs",
"prebuild": "node ./scripts/sync-css.mjs"
"prebuild": "node ./scripts/sync-css.mjs",
"test": "node test/unit.mjs"
},
"dependencies": {
"@astrojs/node": "^9.1.3",
@@ -19,4 +20,4 @@
"nodemailer": "^6.10.1",
"stripe": "^17.5.0"
}
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
---
import { sanitizeHtml } from '../lib/sanitize.js';
import { listFeatured, listProducts, listActiveSlides, formatPrice, basePriceLabel } from '../lib/store.js';
export interface Props { blocks?: any[] }
@@ -31,7 +32,7 @@ const galCols = (b) => Math.max(2, Math.min(4, Number(b.columns) || 3));
)}
{b.type === 'richtext' && (
<section class="blk blk-rich"><div class="wrap prose" set:html={b.html || ''}></div></section>
<section class="blk blk-rich"><div class="wrap prose" set:html={sanitizeHtml(b.html || '')}></div></section>
)}
{b.type === 'image' && (
@@ -101,6 +102,6 @@ const galCols = (b) => Math.max(2, Math.min(4, Number(b.columns) || 3));
{b.type === 'spacer' && (<div class="blk-spacer" style={`height:${spacerPx[b.size] || 56}px`}></div>)}
{b.type === 'html' && (<section class="blk blk-html"><div set:html={b.code || ''}></div></section>)}
{b.type === 'html' && (<section class="blk blk-html"><div set:html={sanitizeHtml(b.code || '')}></div></section>)}
</>
))}
+4 -1
View File
@@ -1,4 +1,5 @@
// hd-commerce — Token-gesicherte Admin-JSON-API (für KI/MCP).
import { timingSafeEqual as _tse } from 'node:crypto';
// Bearer-Token aus ENV HDC_API_TOKEN (getrennt von der Session-Auth).
import * as store from './store.js';
import { BLOCK_TYPES } from './blocks.js';
@@ -11,7 +12,9 @@ export function authOk(request) {
if (!token) return false; // ohne konfiguriertes Token bleibt die API gesperrt
const hdr = request.headers.get('authorization') || '';
const m = hdr.match(/^Bearer\s+(.+)$/i);
return !!m && m[1].trim() === token;
if (!m) return false;
const a = Buffer.from(m[1].trim()), b = Buffer.from(token);
return a.length === b.length && _tse(a, b);
}
// ---- Ressourcen-Definitionen für das Manifest ----
+17 -4
View File
@@ -1,8 +1,19 @@
// hd-commerce — Session-Auth (stateless signiertes Cookie), Rollen-Gate, Rate-Limit.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { getUserById } from './store.js';
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
import { getUserById, getSetting, setSetting } from './store.js';
const SECRET = process.env.SESSION_SECRET || 'hd-commerce-dev-secret-change-me';
function resolveSecret() {
const env = (process.env.SESSION_SECRET || '').trim();
if (env && env.length >= 16) return env;
// Kein/zu kurzes ENV-Secret → persistiertes Zufalls-Secret (stabil über Restarts, NICHT der bekannte Default).
try {
let sec = getSetting('session_secret');
if (!sec) { sec = randomBytes(32).toString('hex'); setSetting('session_secret', sec); }
if (!env) console.warn('[hd-commerce] SESSION_SECRET nicht gesetzt — nutze persistiertes Zufalls-Secret. Für Mehr-Instanz/Skalierung SESSION_SECRET als ENV setzen.');
return sec;
} catch { return env || randomBytes(32).toString('hex'); }
}
const SECRET = resolveSecret();
export const COOKIE_NAME = 'hdc_session';
// --- konfigurierbarer Admin-Pfad ---
@@ -44,13 +55,15 @@ export function verifySession(token) {
return data;
}
const cookieSecure = () => (process.env.PUBLIC_BASE_URL || '').startsWith('https') || process.env.COOKIE_SECURE === '1';
export function buildCookie(token, remember) {
const parts = [`${COOKIE_NAME}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
if (cookieSecure()) parts.push('Secure');
if (remember) parts.push('Max-Age=' + (60 * 60 * 24 * 30));
return parts.join('; ');
}
export function clearCookie() {
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax;${cookieSecure() ? ' Secure;' : ''} Max-Age=0`;
}
export function parseCookies(request) {
+17
View File
@@ -0,0 +1,17 @@
// Leichtgewichtiger HTML-Sanitizer für Admin-/AI-erstellte Inhalte (richtext/html-Blöcke, Seiten-Body).
// Entfernt aktive Vektoren (script/style/link/meta/object/embed, on*-Handler, javascript:/data:text-html-URLs).
// iframe bleibt erlaubt (Embeds wie Karten/Video), da kein direktes Eltern-XSS.
export function sanitizeHtml(input) {
if (input == null) return '';
let s = String(input);
// gefährliche Elemente inkl. Inhalt entfernen
s = s.replace(/<\s*(script|style|object|embed)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
// selbstschließende/lose gefährliche Tags
s = s.replace(/<\s*(script|style|link|meta|object|embed)\b[^>]*>/gi, '');
// Inline-Event-Handler (onclick=, onerror=, …)
s = s.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
// javascript:- und data:text/html-URLs in href/src neutralisieren
s = s.replace(/(href|src|xlink:href)\s*=\s*("\s*javascript:[^"]*"|'\s*javascript:[^']*'|javascript:[^\s>]+)/gi, '$1="#"');
s = s.replace(/(href|src)\s*=\s*("\s*data:text\/html[^"]*"|'\s*data:text\/html[^']*')/gi, '$1="#"');
return s;
}
+2 -1
View File
@@ -2,6 +2,7 @@
import Base from '../../layouts/Base.astro';
import BlockRenderer from '../../components/BlockRenderer.astro';
import { getPageBySlug } from '../../lib/store.js';
import { sanitizeHtml } from '../../lib/sanitize.js';
const { slug } = Astro.params;
const page = getPageBySlug(slug);
@@ -15,7 +16,7 @@ const hasBlocks = Array.isArray(page.blocks) && page.blocks.length > 0;
<div class="wrap">
<article class="prose">
<h1>{page.title}</h1>
<div set:html={page.body}></div>
<div set:html={sanitizeHtml(page.body)}></div>
</article>
</div>
)}
+33
View File
@@ -0,0 +1,33 @@
// hd-commerce — Unit-Tests für Shop-Mathematik + Sanitizer. Lauf: npm test
import assert from 'node:assert';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), 'hdc-test-')), 't.db');
const store = await import('../src/lib/store-sqlite.js');
const { sanitizeHtml } = await import('../src/lib/sanitize.js');
let pass = 0, fail = 0;
const t = (name, fn) => { try { fn(); pass++; console.log(' ✓', name); } catch (e) { fail++; console.error(' ✗', name, '—', e.message); } };
t('taxFromGross 19% aus 119,00 € = 19,00 €', () => assert.strictEqual(store.taxFromGross(11900, 19), 1900));
t('taxFromGross 7% aus 107,00 € = 7,00 €', () => assert.strictEqual(store.taxFromGross(10700, 7), 700));
t('taxFromGross 0% = 0', () => assert.strictEqual(store.taxFromGross(5000, 0), 0));
t('Versand DE gratis ab 49 € (50 € → 0)', () => assert.strictEqual(store.shippingFor('DE', 5000).price_cents, 0));
t('Versand DE unter Schwelle (10 € → 4,90 €)', () => assert.strictEqual(store.shippingFor('DE', 1000).price_cents, 490));
t('Versand CH = 9,90 €', () => assert.strictEqual(store.shippingFor('CH', 5000).price_cents, 990));
t('Gutschein WILLKOMMEN10 = 10% (50 € → 5 €)', () => { const v = store.validateDiscount('WILLKOMMEN10', 5000); assert.ok(v.ok); assert.strictEqual(v.amountCents, 500); });
t('Gutschein NAEHEN5 = 5% (50 € → 2,50 €)', () => { const v = store.validateDiscount('NAEHEN5', 5000); assert.ok(v.ok); assert.strictEqual(v.amountCents, 250); });
t('Gutschein case-insensitiv (willkommen10)', () => assert.ok(store.validateDiscount('willkommen10', 5000).ok));
t('Gutschein unbekannt → ungültig', () => assert.ok(!store.validateDiscount('GIBTSNICHT', 5000).ok));
t('Sanitizer entfernt <script>', () => assert.ok(!/<script/i.test(sanitizeHtml('<p>x</p><script>alert(1)</script>'))));
t('Sanitizer entfernt onerror=', () => assert.ok(!/onerror/i.test(sanitizeHtml('<img src=x onerror=alert(1)>'))));
t('Sanitizer neutralisiert javascript:', () => assert.ok(!/javascript:/i.test(sanitizeHtml('<a href="javascript:alert(1)">x</a>'))));
t('Sanitizer lässt normales Markup', () => assert.ok(/<strong>/.test(sanitizeHtml('<strong>fett</strong>'))));
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);