Analytics: Annahme gehaertet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 09:41:25 +00:00
1 parent 8b107019c3
commit 6233850bb3
4 files changed
+164 -22

No files matched your search

+105 -4
View File
@@ -1,7 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.js';
import { createPublicServer } from '../src/server.js';
import net from 'node:net';
import { createPublicServer, createAdminServer } from '../src/server.js';
import { classifyUa } from '../src/ua.js';
import { createSalt } from '../src/salt.js';
@@ -16,16 +17,17 @@ async function setup({ now = () => Date.UTC(2026, 9, 1, 10, 30, 45), geo = { loo
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)')
.run(KEY, 'p1', JSON.stringify([HOST, 'www.kunde.de']));
const server = createPublicServer({ config: {}, db, geo, now });
const invalidate = () => server.invalidateSites();
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const post = (ev, { origin = ORIGIN, ua = UA, ip = IP, referer, raw, type = 'text/plain' } = {}) => {
const headers = { 'Content-Type': type, 'User-Agent': ua, 'X-Forwarded-For': `1.2.3.4, ${ip}` };
const post = (ev, { origin = ORIGIN, ua = UA, ip = IP, left = '1.2.3.4', referer, raw, type = 'text/plain' } = {}) => {
const headers = { 'Content-Type': type, 'User-Agent': ua, 'X-Forwarded-For': `${left}, ${ip}` };
if (origin) headers.Origin = origin;
if (referer) headers.Referer = referer;
return fetch(base + '/a/e', { method: 'POST', headers, body: raw ?? JSON.stringify(ev) });
};
const rows = () => db.prepare('SELECT * FROM events_raw ORDER BY id').all();
return { db, post, rows, base, close: () => server.close() };
return { db, post, rows, base, server, invalidate, close: () => server.close() };
}
const pv = (o = {}) => ({ t: 'pv', site: KEY, path: '/', vp: 'd', ...o });
@@ -249,3 +251,102 @@ test('Datenschutz: weder IP noch User-Agent irgendwo in der Datenbank', async ()
for (const needle of [IP, '1.2.3.4', 'GEHEIMERUA', 'Mozilla', 'iPhone']) assert.ok(!dump.includes(needle), needle);
t.close();
});
test('t: nur eigene Feldlisten, kein Prototyp-Zugriff, keine Logzeile', async () => {
const t = await setup();
const errs = [];
const orig = console.error;
console.error = (...a) => errs.push(a.join(' '));
try {
for (const tt of ['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf']) {
assert.equal((await t.post(null, { raw: JSON.stringify({ t: tt, site: KEY, path: '/', vp: 'd' }) })).status, 204);
}
assert.equal((await t.post(null, { raw: `{"t":"__proto__","site":"${KEY}","path":"/"}` })).status, 204);
assert.equal((await t.post({ t: ['pv'], site: KEY, path: '/', vp: 'd' })).status, 204);
assert.equal((await t.post({ t: { toString: 1 }, site: KEY, path: '/', vp: 'd' })).status, 204);
} finally { console.error = orig; }
assert.equal(t.rows().length, 0);
assert.deepEqual(errs, []);
t.close();
});
test('Origin/Referer: nur https ohne Port zählt', async () => {
const t = await setup();
for (const origin of [`http://${HOST}`, `https://${HOST}:8080`, `https://evil-${HOST}`, `https://${HOST}.evil.com`, `ftp://${HOST}`]) {
const res = await t.post(pv(), { origin });
assert.equal(res.headers.get('access-control-allow-origin'), null, origin);
const pre = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: origin } });
assert.equal(pre.headers.get('access-control-allow-origin'), null, origin);
}
await t.post(pv(), { origin: null, referer: `http://${HOST}/x` });
await t.post(pv(), { origin: null, referer: `https://${HOST}:8080/x` });
assert.equal(t.rows().length, 0);
await t.post(pv(), { origin: null, referer: `https://${HOST}/x` });
assert.equal(t.rows().length, 1);
t.close();
});
function rawRequest(port, head, body = '') {
return new Promise((resolve) => {
const s = net.connect(port, '127.0.0.1');
let buf = '';
s.on('connect', () => s.write(head + '\r\n\r\n' + body));
s.on('data', (d) => { buf += d; });
s.on('close', () => resolve(buf));
s.on('error', () => resolve(buf));
setTimeout(() => { s.destroy(); }, 1500);
});
}
test('Body: früh 413 bei Content-Length > 4096, Abbruch bei gestreamtem Überlauf', async () => {
const t = await setup();
const port = t.server.address().port;
const head = `POST /a/e HTTP/1.1\r\nHost: x\r\nOrigin: ${ORIGIN}\r\nContent-Type: text/plain`;
// Content-Length 1 MB angekündigt, aber nichts gesendet: Antwort kommt trotzdem sofort
const early = await rawRequest(port, `${head}\r\nContent-Length: 1048576`);
assert.match(early, /^HTTP\/1\.1 413/);
// chunked ohne Content-Length: über 4 KB ⇒ 413 und Verbindung wird beendet
const chunk = 'x'.repeat(3000);
const body = `${chunk.length.toString(16)}\r\n${chunk}\r\n`.repeat(3);
const streamed = await rawRequest(port, `${head}\r\nTransfer-Encoding: chunked`, body);
assert.match(streamed, /^HTTP\/1\.1 413/);
assert.equal(t.rows().length, 0);
t.close();
});
test('Preflight: Hostliste gecacht (30 s), invalidateSites() und Admin-Änderung wirken sofort', async () => {
let now = Date.UTC(2026, 9, 1, 10, 0, 0);
const t = await setup({ now: () => now });
const pre = async () => (await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: ORIGIN } })).headers.get('access-control-allow-origin');
assert.equal(await pre(), ORIGIN);
t.db.prepare('UPDATE sites SET active = 0').run();
assert.equal(await pre(), ORIGIN); // noch aus dem Cache
now += 31000;
assert.equal(await pre(), null); // Cache abgelaufen
t.db.prepare('UPDATE sites SET active = 1').run();
assert.equal(await pre(), null);
t.invalidate();
assert.equal(await pre(), ORIGIN);
// Admin-Listener meldet Änderungen an den öffentlichen Listener
const admin = createAdminServer({ config: { adminToken: 'geheim' }, db: t.db, onSitesChanged: () => t.invalidate() });
await new Promise((r) => admin.listen(0, '127.0.0.1', r));
const res = await fetch(`http://127.0.0.1:${admin.address().port}/a/admin/sites/${KEY}`, {
method: 'PUT', headers: { Authorization: 'Bearer geheim', 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: 'p1', domains: [HOST], active: false }),
});
assert.equal(res.status, 200);
assert.equal(await pre(), null);
admin.close();
t.close();
});
test('X-Forwarded-For: rechtester Eintrag zählt für das Mengenlimit', async () => {
const t = await setup();
for (let i = 0; i < 120; i++) await t.post(pv(), { left: `10.0.0.${i % 250}` }); // links variiert, rechts gleich
assert.equal(t.rows().length, 120);
await t.post(pv(), { left: '10.9.9.9' }); // gleicher Bucket ⇒ verworfen
assert.equal(t.rows().length, 120);
await t.post(pv(), { left: '1.2.3.4', ip: '198.51.100.9' }); // rechts anders ⇒ eigener Bucket
assert.equal(t.rows().length, 121);
t.close();
});