chore: monorepo - plugin, backend und hilfsdaten in einem repo

- Eltern-Ordner ist jetzt EIN Git-Repo (statt getrennter Repos).
- root .gitignore haelt Secrets (.env), node_modules, DB und Build-Artefakte raus.
- release.ps1: manueller Release (ZIP bauen + ans Backend laden).
- root README mit Struktur und Release-Ablauf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-06-07 14:41:38 +02:00
commit ecb5e1bd22
37 changed files with 4390 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { pathToFileURL } from 'node:url';
import { resolve as pathResolve } from 'node:path';
const shim = pathToFileURL( pathResolve( 'test/shim-better-sqlite3.mjs' ) ).href;
export async function resolve( specifier, context, next ) {
if ( specifier === 'better-sqlite3' ) {
return { url: shim, shortCircuit: true };
}
return next( specifier, context );
}

View File

@@ -0,0 +1,212 @@
// End-to-end test of the REAL server.js (better-sqlite3 shimmed to node:sqlite).
import { rmSync } from 'node:fs';
const PORT = 8799;
const TOKEN = 'test-admin-token';
const BASE = `http://127.0.0.1:${PORT}`;
process.env.ADMIN_API_TOKEN = TOKEN;
process.env.PORT = String(PORT);
process.env.DATA_DIR = './.testdata';
process.env.SEED_PRODUCTS = 'gdpr-content-blocker:Content Blocker';
process.env.PUBLIC_BASE_URL = BASE;
rmSync('./.testdata', { recursive: true, force: true });
let fail = 0;
const ok = (name, cond, extra = '') => {
console.log((cond ? '[PASS] ' : '[FAIL] ') + name + (cond ? '' : ' ' + extra));
if (!cond) fail++;
};
async function admin(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: { 'Content-Type': 'application/json', 'X-Admin-Token': TOKEN },
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, json: await r.json() };
}
async function pub(path, body) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: r.status, json: await r.json() };
}
async function adminRaw(path, buffer, headers = {}) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/zip', 'X-Admin-Token': TOKEN, ...headers },
body: buffer,
});
return { status: r.status, json: await r.json() };
}
// Boot the real server.
await import('../src/server.js');
await new Promise((r) => setTimeout(r, 400));
try {
const P = 'gdpr-content-blocker';
// Health
ok('healthz', (await (await fetch(BASE + '/healthz')).json()).ok === true);
// Admin auth required
const noAuth = await fetch(BASE + '/api/v1/licenses', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: P, max_activations: 1 }),
});
ok('generate without token → 401', noAuth.status === 401);
// Generate single-site key (max=1)
const g1 = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 1, email: 'a@b.de' });
ok('generate key 201', g1.status === 201 && g1.json.ok === true, JSON.stringify(g1.json));
const key1 = g1.json.key;
ok('key format', /^[A-Z2-9]{4}(-[A-Z2-9]{4}){3}$/.test(key1 || ''), key1);
// Activate domain A
const a1 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'https://www.Kunde-A.de/' });
ok('activate A valid', a1.json.status === 'valid' && a1.json.activations_used === 1, JSON.stringify(a1.json));
// Re-activate same domain (idempotent, count stays 1)
const a1b = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('re-activate A idempotent', a1b.json.ok && a1b.json.activations_used === 1, JSON.stringify(a1b.json));
// Activate second domain → limit reached (max=1), response lists bound domains
const a2 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-b.de' });
ok('second domain → 409 limit', a2.status === 409 && a2.json.ok === false, JSON.stringify(a2.json));
ok('409 reports code limit_reached', a2.json.code === 'limit_reached', JSON.stringify(a2.json));
ok('409 lists occupied domain', Array.isArray(a2.json.domains) && a2.json.domains.includes('kunde-a.de'), JSON.stringify(a2.json));
// Validate activated domain
const v1 = await pub('/api/v1/validate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('validate A valid', v1.json.status === 'valid', JSON.stringify(v1.json));
// Validate non-activated domain
const v2 = await pub('/api/v1/validate', { key: key1, product: P, domain: 'kunde-x.de' });
ok('validate unbound → 403', v2.status === 403 && v2.json.ok === false, JSON.stringify(v2.json));
// Deactivate A, then B should fit
const d1 = await pub('/api/v1/deactivate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('deactivate A', d1.json.ok === true);
const a3 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-b.de' });
ok('after deactivate, B fits', a3.json.status === 'valid', JSON.stringify(a3.json));
// 3-site key
const g3 = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 3 });
const key3 = g3.json.key;
for (const d of ['s1.de', 's2.de', 's3.de']) {
const r = await pub('/api/v1/activate', { key: key3, product: P, domain: d });
ok('3-site activate ' + d, r.json.status === 'valid', JSON.stringify(r.json));
}
const g3over = await pub('/api/v1/activate', { key: key3, product: P, domain: 's4.de' });
ok('3-site 4th → 409', g3over.status === 409, JSON.stringify(g3over.json));
// Unlimited key
const gU = await admin('POST', '/api/v1/licenses', { product: P, max_activations: -1 });
const keyU = gU.json.key;
let allOk = true;
for (let i = 0; i < 10; i++) {
const r = await pub('/api/v1/activate', { key: keyU, product: P, domain: `u${i}.de` });
if (r.json.status !== 'valid') allOk = false;
}
ok('unlimited activates 10 domains', allOk);
// Disable a license → activation blocked
await admin('POST', `/api/v1/licenses/${key3}/disable`);
const disabled = await pub('/api/v1/activate', { key: key3, product: P, domain: 'new.de' });
ok('disabled license → 403', disabled.status === 403, JSON.stringify(disabled.json));
// Wrong product
const wrong = await pub('/api/v1/activate', { key: key1, product: 'nope', domain: 'kunde-b.de' });
ok('unknown product → 404', wrong.status === 404, JSON.stringify(wrong.json));
// Unknown key
const unk = await pub('/api/v1/activate', { key: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', product: P, domain: 'x.de' });
ok('unknown key → 404', unk.status === 404, JSON.stringify(unk.json));
// Invalid max_activations
const badMax = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 0 });
ok('max_activations 0 → 400', badMax.status === 400, JSON.stringify(badMax.json));
// Add a new product (extensibility) and issue a key for it
const np = await admin('POST', '/api/v1/products', { slug: 'next-plugin', name: 'Next Plugin' });
ok('add product 201', np.status === 201);
const npKey = await admin('POST', '/api/v1/licenses', { product: 'next-plugin', max_activations: 1 });
ok('key for new product', npKey.status === 201 && npKey.json.ok, JSON.stringify(npKey.json));
// Inspect a license
const info = await admin('GET', `/api/v1/licenses/${key1}`);
ok('inspect shows 1 domain (kunde-b)', info.json.activations_used === 1 && info.json.domains[0].domain === 'kunde-b.de', JSON.stringify(info.json));
// ── Scan endpoint guards ──
// Unactivated domain → 403
const scanUnbound = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-a.de' });
ok('scan unbound domain → 403', scanUnbound.status === 403, JSON.stringify(scanUnbound.json));
// SSRF guard: target url on a different host than the licensed domain → 400
const scanWrongHost = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-b.de', urls: ['https://evil.example.org/'] });
ok('scan foreign host filtered → 400', scanWrongHost.status === 400, JSON.stringify(scanWrongHost.json));
// Private/loopback licensed domain rejected
const scanLocal = await pub('/api/v1/scan', { key: key1, product: P, domain: '127.0.0.1' });
ok('scan private domain → 400', scanLocal.status === 400, JSON.stringify(scanLocal.json));
// Valid bound domain: host is unreachable in test, so endpoint returns ok:true
// with a per-page fetch error and empty findings (pipeline wiring works).
const scanOk = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-b.de' });
ok('scan bound domain → 200 ok', scanOk.status === 200 && scanOk.json.ok === true, JSON.stringify(scanOk.json));
ok('scan reports the attempted page', Array.isArray(scanOk.json.scanned) && scanOk.json.scanned.length === 1, JSON.stringify(scanOk.json));
// ── Update delivery ──
// A minimal but valid ZIP (PK signature). key1 is active on kunde-b.de.
const zip = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4, 5, 6, 7, 8]);
const upBad = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=bad', zip);
ok('upload bad version → 400', upBad.status === 400, JSON.stringify(upBad.json));
const upEmpty = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=1.1.0', Buffer.alloc(0));
ok('upload empty body → 400', upEmpty.status === 400, JSON.stringify(upEmpty.json));
const up = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=1.1.0', zip, { 'X-Changelog': 'Neues' });
ok('upload release 201', up.status === 201 && up.json.bytes === zip.length, JSON.stringify(up.json));
// No update when current >= latest
const uNo = await pub('/api/v1/update', { key: key1, product: P, domain: 'kunde-b.de', version: '1.1.0' });
ok('update: current==latest → none', uNo.json.ok && uNo.json.update_available === false, JSON.stringify(uNo.json));
// Update available when current < latest
const uYes = await pub('/api/v1/update', { key: key1, product: P, domain: 'kunde-b.de', version: '1.0.0' });
ok('update available', uYes.json.update_available === true && uYes.json.version === '1.1.0', JSON.stringify(uYes.json));
ok('update has package url', typeof uYes.json.package === 'string' && uYes.json.package.includes('/api/v1/download?token='), JSON.stringify(uYes.json));
// Unactivated domain cannot check updates
const uUnbound = await pub('/api/v1/update', { key: key1, product: P, domain: 'fremd.de', version: '1.0.0' });
ok('update unbound domain → 403', uUnbound.status === 403, JSON.stringify(uUnbound.json));
// Download via the signed package URL → real ZIP bytes
const dl = await fetch(uYes.json.package);
const dlBuf = Buffer.from(await dl.arrayBuffer());
ok('download 200 zip', dl.status === 200 && dl.headers.get('content-type') === 'application/zip', String(dl.status));
ok('download bytes match upload', dlBuf.length === zip.length && dlBuf[0] === 0x50 && dlBuf[1] === 0x4b);
// Tampered token rejected
const badToken = uYes.json.package.replace('token=', 'token=x');
const dlBad = await fetch(badToken);
ok('download tampered token → 403', dlBad.status === 403, String(dlBad.status));
// Admin can list releases
const relList = await admin('GET', '/api/v1/releases/gdpr-content-blocker');
ok('release list shows 1.1.0', relList.json.ok && relList.json.releases.some((r) => r.version === '1.1.0'), JSON.stringify(relList.json));
} catch (e) {
console.error(e);
fail++;
}
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
try { rmSync('./.testdata', { recursive: true, force: true }); } catch { /* WAL handle still open; harmless */ }
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,4 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register( './test/hooks.mjs', pathToFileURL( './' ) );

View File

@@ -0,0 +1,87 @@
import { extractResources, analyze, isPublicHost, isPrivateIp } from '../src/scan.js';
let fail = 0;
const ok = (name, cond, extra = '') => {
console.log((cond ? '[PASS] ' : '[FAIL] ') + name + (cond ? '' : ' ' + extra));
if (!cond) fail++;
};
const html = `
<!DOCTYPE html><html><head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">
<script src="/wp-includes/js/jquery.js"></script>
<script src="https://www.googletagmanager.com/gtag/js?id=G-XYZ"></script>
</head><body>
<img src="https://cdn.example.com/logo.png">
<img src="/local/pic.png">
<iframe src="https://www.google.com/maps/embed?pb=1" width="600" height="450"></iframe>
<iframe src="https://www.youtube-nocookie.com/embed/abc"></iframe>
<iframe src="data:text/html,nope"></iframe>
<object data="https://player.vimeo.com/video/123"></object>
<a href="https://twitter.com/foo">x</a>
</body></html>`;
const base = 'https://example.com/page/';
const resources = extractResources(html, base);
ok('finds google maps iframe', resources.some((r) => r.type === 'iframe' && r.url.includes('google.com/maps')));
ok('finds youtube-nocookie iframe', resources.some((r) => r.url.includes('youtube-nocookie.com/embed')));
ok('skips data: iframe', !resources.some((r) => r.url.startsWith('data:')));
ok('finds external script (gtm)', resources.some((r) => r.type === 'script' && r.url.includes('googletagmanager.com')));
ok('resolves relative script to absolute', resources.some((r) => r.url === 'https://example.com/wp-includes/js/jquery.js'));
ok('finds external img', resources.some((r) => r.type === 'img' && r.url.includes('cdn.example.com')));
ok('finds link stylesheet', resources.some((r) => r.type === 'link' && r.url.includes('fonts.googleapis.com')));
ok('finds object data (vimeo)', resources.some((r) => r.type === 'object' && r.url.includes('player.vimeo.com')));
ok('ignores anchor href', !resources.some((r) => r.url.includes('twitter.com')));
const findings = analyze([{ url: base, resources }], 'example.com');
const byHost = Object.fromEntries(findings.map((f) => [f.host, f]));
ok('example.com is first-party', byHost['example.com'] && byHost['example.com'].third_party === false);
ok('cdn.example.com is first-party (subdomain)', byHost['cdn.example.com'].third_party === false);
ok('google.com is third-party', byHost['google.com'].third_party === true);
ok('googletagmanager third-party', byHost['googletagmanager.com'].third_party === true);
ok('third-party sorted first', findings[0].third_party === true);
ok('suggested_pattern = host', byHost['player.vimeo.com'].suggested_pattern === 'player.vimeo.com');
ok('types aggregated', Array.isArray(byHost['google.com'].types) && byHost['google.com'].types.includes('iframe'));
ok('sample_urls capped at 3', findings.every((f) => f.sample_urls.length <= 3));
ok('pages list source page', Array.isArray(byHost['google.com'].pages) && byHost['google.com'].pages.includes(base));
// pages aggregate across multiple scanned pages
const multi = analyze([
{ url: 'https://example.com/a', resources: [{ url: 'https://google.com/maps', type: 'iframe' }] },
{ url: 'https://example.com/b', resources: [{ url: 'https://google.com/maps', type: 'iframe' }] },
], 'example.com');
ok('host found on both pages', multi[0].pages.length === 2 && multi[0].pages.includes('https://example.com/a') && multi[0].pages.includes('https://example.com/b'));
// www normalization: www.google.com counts as google.com
const f2 = analyze([{ url: base, resources: [{ url: 'https://www.google.com/x', type: 'iframe' }] }], 'example.com');
ok('www stripped in host grouping', f2[0].host === 'google.com');
// isPublicHost
ok('localhost not public', isPublicHost('localhost') === false);
ok('127.0.0.1 not public', isPublicHost('127.0.0.1') === false);
ok('10.x not public', isPublicHost('10.1.2.3') === false);
ok('192.168 not public', isPublicHost('192.168.0.1') === false);
ok('172.16 not public', isPublicHost('172.16.0.1') === false);
ok('172.32 IS public', isPublicHost('172.32.0.1') === true);
ok('public domain ok', isPublicHost('kunde-a.de') === true);
// isPrivateIp (post-DNS-resolution SSRF guard)
ok('metadata 169.254.169.254 private', isPrivateIp('169.254.169.254') === true);
ok('127.0.0.1 private', isPrivateIp('127.0.0.1') === true);
ok('10.x private', isPrivateIp('10.0.0.5') === true);
ok('192.168 private', isPrivateIp('192.168.1.1') === true);
ok('172.16 private', isPrivateIp('172.16.5.5') === true);
ok('172.32 public ip', isPrivateIp('172.32.0.1') === false);
ok('CGNAT 100.64 private', isPrivateIp('100.64.0.1') === true);
ok('public ip allowed', isPrivateIp('93.184.216.34') === false);
ok('::1 private', isPrivateIp('::1') === true);
ok('fe80 link-local private', isPrivateIp('fe80::1') === true);
ok('fd00 ula private', isPrivateIp('fd12::1') === true);
ok('ipv4-mapped metadata private', isPrivateIp('::ffff:169.254.169.254') === true);
ok('global ipv6 allowed', isPrivateIp('2606:2800:220:1:248:1893:25c8:1946') === false);
ok('empty ip unsafe', isPrivateIp('') === true);
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,24 @@
// Test-only shim: maps the better-sqlite3 API onto Node's built-in node:sqlite.
// Used ONLY for local integration testing (production uses real better-sqlite3
// inside the Docker image). The SQL executed is identical.
import { DatabaseSync } from 'node:sqlite';
class Statement {
constructor(stmt) {
this.stmt = stmt;
if (typeof stmt.setAllowBareNamedParameters === 'function') {
stmt.setAllowBareNamedParameters(true);
}
}
run(...args) { return this.stmt.run(...args); }
get(...args) { return this.stmt.get(...args); }
all(...args) { return this.stmt.all(...args); }
}
export default class Database {
constructor(path) { this.db = new DatabaseSync(path); }
pragma(s) { return this.db.exec('PRAGMA ' + s + ';'); }
exec(sql) { return this.db.exec(sql); }
prepare(sql) { return new Statement(this.db.prepare(sql)); }
close() { this.db.close(); }
}

View File

@@ -0,0 +1,50 @@
import { generateKey, normalizeDomain, safeEqual, isExpired, compareVersions, signToken, verifyToken } from '../src/util.js';
let fail = 0;
const ok = (name, cond) => { console.log((cond ? '[PASS] ' : '[FAIL] ') + name); if (!cond) fail++; };
// Key format
const k = generateKey();
ok('key format XXXX-XXXX-XXXX-XXXX', /^[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(k));
ok('no ambiguous chars (0OI1L)', !/[0OI1L]/.test(k));
const keys = new Set(Array.from({ length: 1000 }, () => generateKey()));
ok('1000 keys unique', keys.size === 1000);
// Domain normalization
ok('strip https + www', normalizeDomain('https://www.Example.com/') === 'example.com');
ok('strip path', normalizeDomain('http://example.com/foo/bar') === 'example.com');
ok('strip port', normalizeDomain('example.com:8080') === 'example.com');
ok('subdomain kept', normalizeDomain('https://shop.example.com/x') === 'shop.example.com');
ok('empty stays empty', normalizeDomain('') === '');
ok('null safe', normalizeDomain(null) === '');
// safeEqual
ok('safeEqual match', safeEqual('abc123', 'abc123') === true);
ok('safeEqual mismatch', safeEqual('abc', 'abd') === false);
ok('safeEqual length diff', safeEqual('abc', 'abcd') === false);
// isExpired
ok('no expiry = not expired', isExpired(null) === false);
ok('past = expired', isExpired('2000-01-01T00:00:00Z') === true);
ok('future = not expired', isExpired('2999-01-01T00:00:00Z') === false);
ok('garbage = not expired', isExpired('not-a-date') === false);
// compareVersions
ok('1.1.0 > 1.0.0', compareVersions('1.1.0', '1.0.0') === 1);
ok('1.0.0 < 1.0.1', compareVersions('1.0.0', '1.0.1') === -1);
ok('1.0 == 1.0.0', compareVersions('1.0', '1.0.0') === 0);
ok('2.0.0 > 1.9.9', compareVersions('2.0.0', '1.9.9') === 1);
ok('10.0 > 9.0 (numeric not lexical)', compareVersions('10.0', '9.0') === 1);
// signToken / verifyToken
const secret = 'top-secret';
const tok = signToken({ k: 'KEY', p: 'gdpr-content-blocker', v: '1.1.0', exp: Date.now() + 10000 }, secret);
const payload = verifyToken(tok, secret);
ok('token round-trips', payload && payload.k === 'KEY' && payload.v === '1.1.0');
ok('wrong secret → null', verifyToken(tok, 'other') === null);
ok('tampered body → null', verifyToken('x' + tok, secret) === null);
ok('expired token → null', verifyToken(signToken({ exp: Date.now() - 1 }, secret), secret) === null);
ok('garbage token → null', verifyToken('not-a-token', secret) === null);
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
process.exit(fail === 0 ? 0 : 1);