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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user