Analyse: Skript nur für aktive Websites, leeres Skript sonst, siteKey in der Skript-URL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:26 +00:00
1 parent 97890a8eee
commit c6824f83a6
5 files changed
+76 -10

No files matched your search

+23 -1
View File
@@ -9,7 +9,10 @@ import crypto from 'node:crypto';
const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist'); const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
const FILES = { '/a/w.js': 'w.js', '/a/h.js': 'h.js' }; const FILES = { '/a/w.js': 'w.js', '/a/h.js': 'h.js' };
export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) { const RE_KEY = /^[a-z0-9]{16,40}$/;
const EMPTY = Buffer.from('/* WEBklar Analyse: aus */\n');
export function createAssets({ distDir = DEFAULT_DIR, log = console, isActive = () => true } = {}) {
const assets = new Map(); const assets = new Map();
for (const [path, file] of Object.entries(FILES)) { for (const [path, file] of Object.entries(FILES)) {
try { try {
@@ -28,6 +31,25 @@ export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) {
res.end(); res.end();
return true; return true;
} }
if (pathname === '/a/w.js') {
let key = '';
try { key = new URL(req.url, 'http://x').searchParams.get('s') || ''; } catch { /* leer */ }
let on = false;
try { on = RE_KEY.test(key) && !!isActive(key); } catch (e) { log.error('Analytics: isActive:', e.message); }
if (!on) {
res.writeHead(200, {
'Content-Type': 'text/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=60',
Vary: 'Accept-Encoding',
'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'cross-origin',
'Access-Control-Allow-Origin': '*',
'Content-Length': EMPTY.length,
});
res.end(req.method === 'HEAD' ? undefined : EMPTY);
return true;
}
}
const a = assets.get(pathname); const a = assets.get(pathname);
if (!a) { if (!a) {
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+1 -1
View File
@@ -61,7 +61,7 @@ function validateSite(b) {
function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) { function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) {
const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null; const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null;
const heat = role === 'public' ? createHeat({ db, config, now }) : null; const heat = role === 'public' ? createHeat({ db, config, now }) : null;
const assets = role === 'public' ? createAssets({ distDir: config.distDir }) : null; const assets = role === 'public' ? createAssets({ distDir: config.distDir, isActive: (k) => !!db.prepare('SELECT 1 FROM sites WHERE siteKey = ? AND active = 1').get(k) }) : null;
const clock = now || Date.now; const clock = now || Date.now;
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
try { try {
+50 -6
View File
@@ -11,8 +11,13 @@ import { createPublicServer, createAdminServer } from '../src/server.js';
const DIST = new URL('../client/dist/', import.meta.url); const DIST = new URL('../client/dist/', import.meta.url);
const KEY = 'abcdef0123456789abcd';
const OFFKEY = 'offoffoffoffoffoffoff';
async function start(config = {}) { async function start(config = {}) {
const server = createPublicServer({ config, db: openDb(':memory:') }); const db = openDb(':memory:');
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '[]');
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,0)').run(OFFKEY, 'p', '[]');
const server = createPublicServer({ config, db });
await new Promise((r) => server.listen(0, '127.0.0.1', r)); await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }; return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
} }
@@ -35,7 +40,7 @@ for (const file of ['w.js', 'h.js']) {
const s = await start(); const s = await start();
const want = readFileSync(new URL(file, DIST)); const want = readFileSync(new URL(file, DIST));
const etag = `"${crypto.createHash('sha256').update(want).digest('hex')}"`; const etag = `"${crypto.createHash('sha256').update(want).digest('hex')}"`;
const id = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'identity' }); const id = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'identity' });
assert.equal(id.status, 200); assert.equal(id.status, 200);
assert.deepEqual(id.body, want); assert.deepEqual(id.body, want);
assert.equal(id.headers['content-type'], 'text/javascript; charset=utf-8'); assert.equal(id.headers['content-type'], 'text/javascript; charset=utf-8');
@@ -46,7 +51,7 @@ for (const file of ['w.js', 'h.js']) {
assert.equal(id.headers['cross-origin-resource-policy'], 'cross-origin'); assert.equal(id.headers['cross-origin-resource-policy'], 'cross-origin');
assert.equal(id.headers['access-control-allow-origin'], '*'); assert.equal(id.headers['access-control-allow-origin'], '*');
assert.equal(id.headers['content-encoding'], undefined); assert.equal(id.headers['content-encoding'], undefined);
const gz = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'gzip, deflate' }); const gz = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'gzip, deflate' });
assert.equal(gz.status, 200); assert.equal(gz.status, 200);
assert.equal(gz.headers['content-encoding'], 'gzip'); assert.equal(gz.headers['content-encoding'], 'gzip');
assert.equal(gz.headers.etag, etag); assert.equal(gz.headers.etag, etag);
@@ -58,13 +63,13 @@ for (const file of ['w.js', 'h.js']) {
test('ETag: 304 bei If-None-Match, 200 bei anderem Wert', async () => { test('ETag: 304 bei If-None-Match, 200 bei anderem Wert', async () => {
const s = await start(); const s = await start();
const first = await raw(s.base, '/a/w.js'); const first = await raw(s.base, `/a/w.js?s=${KEY}`);
const hit = await raw(s.base, '/a/w.js', { 'If-None-Match': first.headers.etag }); const hit = await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': first.headers.etag });
assert.equal(hit.status, 304); assert.equal(hit.status, 304);
assert.equal(hit.body.length, 0); assert.equal(hit.body.length, 0);
assert.equal(hit.headers.etag, first.headers.etag); assert.equal(hit.headers.etag, first.headers.etag);
assert.equal(hit.headers['cache-control'], 'public, max-age=3600'); assert.equal(hit.headers['cache-control'], 'public, max-age=3600');
assert.equal((await raw(s.base, '/a/w.js', { 'If-None-Match': '"anders"' })).status, 200); assert.equal((await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': '"anders"' })).status, 200);
s.close(); s.close();
}); });
@@ -102,3 +107,42 @@ test('fehlende dist-Datei: 503 nur für diesen Pfad, deutscher Fehler im Log', a
assert.equal((await fetch(`${s.base}/a/h.js`)).status, 503); assert.equal((await fetch(`${s.base}/a/h.js`)).status, 503);
s.close(); s.close();
}); });
const EMPTY = '/* WEBklar Analyse: aus */\n';
for (const [name, q] of [['unbekannter Schlüssel', '?s=unbekannt0123456789ab'], ['ungültiger Schlüssel', '?s=ABC'], ['ohne s', ''], ['ausgeschaltete Website', `?s=${OFFKEY}`]]) {
test(`w.js ${name}: leeres Skript`, async () => {
const s = await start();
const r = await raw(s.base, `/a/w.js${q}`, { 'Accept-Encoding': 'gzip' });
assert.equal(r.status, 200);
assert.equal(r.body.toString(), EMPTY);
assert.equal(r.headers['content-type'], 'text/javascript; charset=utf-8');
assert.equal(r.headers['cache-control'], 'public, max-age=60');
assert.equal(r.headers['x-content-type-options'], 'nosniff');
assert.equal(r.headers['cross-origin-resource-policy'], 'cross-origin');
assert.equal(r.headers['access-control-allow-origin'], '*');
assert.equal(r.headers.vary, 'Accept-Encoding');
assert.equal(r.headers['content-encoding'], undefined);
assert.equal(r.headers.etag, undefined);
s.close();
});
}
test('Ende-zu-Ende: Ausschalten wirkt sofort, Einschalten ebenfalls', async () => {
const db = openDb(':memory:');
const pub = createPublicServer({ config: {}, db });
const adm = createAdminServer({ config: { adminToken: 't' }, db });
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
const pb = `http://127.0.0.1:${pub.address().port}`;
const ab = `http://127.0.0.1:${adm.address().port}`;
const put = (active) => fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active }) });
const get = async () => (await raw(pb, `/a/w.js?s=${KEY}`)).body.toString();
assert.equal(await get(), EMPTY);
assert.equal((await put(true)).status, 200);
assert.notEqual(await get(), EMPTY);
assert.equal((await put(false)).status, 200);
assert.equal(await get(), EMPTY);
assert.equal((await put(true)).status, 200);
assert.notEqual(await get(), EMPTY);
pub.close(); adm.close();
});
+1 -1
View File
@@ -17,7 +17,7 @@ export const isValidBase = (b) => typeof b === 'string' && /^https:\/\/[^\s"'<>]
export function scriptTag(analytics, baseUrl) { export function scriptTag(analytics, baseUrl) {
// Attributwerte nur aus geprüften Eingaben (Regex), daher ohne Maskierung sicher. // Attributwerte nur aus geprüften Eingaben (Regex), daher ohne Maskierung sicher.
return `<script data-wk-analytics src="${baseUrl}w.js" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`; return `<script data-wk-analytics src="${baseUrl}w.js?s=${analytics.siteKey}" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
} }
// Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik). // Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik).
+1 -1
View File
@@ -11,7 +11,7 @@ import { createJobs } from '../src/jobs.js';
const BASE = 'https://project.webklar.com/a/'; const BASE = 'https://project.webklar.com/a/';
const KEY = 'abcdef0123456789abcd'; const KEY = 'abcdef0123456789abcd';
const A = (o = {}) => ({ siteKey: KEY, color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }, ...o }); const A = (o = {}) => ({ siteKey: KEY, color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }, ...o });
const TAG = `<script data-wk-analytics src="${BASE}w.js" data-site="${KEY}" data-color="#2b6cb0" defer></script>`; const TAG = `<script data-wk-analytics src="${BASE}w.js?s=${KEY}" data-site="${KEY}" data-color="#2b6cb0" defer></script>`;
const dir = () => mkdtempSync(join(tmpdir(), 'inj-')); const dir = () => mkdtempSync(join(tmpdir(), 'inj-'));
const put = (d, f, c) => { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); }; const put = (d, f, c) => { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); };
const get = (d, f) => readFileSync(join(d, f), 'utf8'); const get = (d, f) => readFileSync(join(d, f), 'utf8');