diff --git a/package-lock.json b/package-lock.json
index 9d712af..73f450c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 6333c9b..5b29965 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
-}
+}
\ No newline at end of file
diff --git a/src/components/BlockRenderer.astro b/src/components/BlockRenderer.astro
index 72a3992..a9223f0 100644
--- a/src/components/BlockRenderer.astro
+++ b/src/components/BlockRenderer.astro
@@ -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' && (
-
+
)}
{b.type === 'image' && (
@@ -101,6 +102,6 @@ const galCols = (b) => Math.max(2, Math.min(4, Number(b.columns) || 3));
{b.type === 'spacer' && (
)}
- {b.type === 'html' && ()}
+ {b.type === 'html' && ()}
>
))}
diff --git a/src/lib/admin-api.js b/src/lib/admin-api.js
index d60aa11..253c0e9 100644
--- a/src/lib/admin-api.js
+++ b/src/lib/admin-api.js
@@ -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 ----
diff --git a/src/lib/auth.js b/src/lib/auth.js
index b8c96e1..ecd5ef5 100644
--- a/src/lib/auth.js
+++ b/src/lib/auth.js
@@ -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) {
diff --git a/src/lib/sanitize.js b/src/lib/sanitize.js
new file mode 100644
index 0000000..4736cdd
--- /dev/null
+++ b/src/lib/sanitize.js
@@ -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;
+}
diff --git a/src/pages/seite/[slug].astro b/src/pages/seite/[slug].astro
index 976bfaa..87d74fc 100644
--- a/src/pages/seite/[slug].astro
+++ b/src/pages/seite/[slug].astro
@@ -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;
)}
diff --git a/test/unit.mjs b/test/unit.mjs
new file mode 100644
index 0000000..4fc1bc0
--- /dev/null
+++ b/test/unit.mjs
@@ -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 '))));
+t('Sanitizer entfernt onerror=', () => assert.ok(!/onerror/i.test(sanitizeHtml('
'))));
+t('Sanitizer neutralisiert javascript:', () => assert.ok(!/javascript:/i.test(sanitizeHtml('x'))));
+t('Sanitizer lässt normales Markup', () => assert.ok(//.test(sanitizeHtml('fett'))));
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);