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;

{page.title}

-
+
)} 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);