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:
Generated
+2
-2
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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>)}
|
||||
</>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user