// Prueft die PRESIGNED URLS (d4rk-rp-planung#239) gegen einen WEGWERF-Container // des fertigen Abbilds -- nicht gegen den laufenden Dienst. // // docker build -t d4rk-media:presign-test server // node tools/presign-pruefen.mjs // // Wie privat-pruefen.mjs: kein Volume, ein Port nur auf 127.0.0.1, ein // Zufallspasswort, das nirgends ausgegeben wird; am Ende wird der Container // entfernt, auch wenn eine Pruefung scheitert. // // DIE KONTROLLEN ZUERST: der gewoehnliche Weg /api/v3/file MIT Token muss // gehen. Erst dann heisst "mit Schluessel abgelehnt" etwas. import { execFileSync } from 'node:child_process' import { createHash, randomBytes } from 'node:crypto' const ABBILD = 'd4rk-media:presign-test' const NAME = 'd4rk-media-presign-test' const PORT = 9197 const BASIS = `http://127.0.0.1:${PORT}` // Ein echtes 1x1-PNG; die Bytes muessen unveraendert zurueckkommen. const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') const sha = (b) => createHash('sha256').update(b).digest('hex') const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) const wegraeumen = () => { try { docker('rm', '-f', NAME) } catch { /* war nicht da */ } } const schlaf = (ms) => new Promise((r) => setTimeout(r, ms)) const ergebnisse = [] function pruefe(id, was, ok, ist) { ergebnisse.push({ id, was, ok, ist }) console.log(`${ok ? 'OK ' : 'FAIL'} ${id.padEnd(4)} ${was} [${ist}]`) } /** multipart wie die NUI eines Handys: Feld "file", optional path. */ const formular = (bytes, typ, name = 'foto.png', pfad) => { const f = new FormData() f.append('file', new Blob([bytes], { type: typ }), name) if (pfad) f.append('path', pfad) return f } async function main() { wegraeumen() const kennwort = randomBytes(18).toString('base64url') docker('run', '-d', '--rm', '--name', NAME, '-p', `127.0.0.1:${PORT}:8080`, '-e', `PUBLIC_URL=${BASIS}`, '-e', 'ADMIN_USERNAME=admin', '-e', `ADMIN_PASSWORD=${kennwort}`, ABBILD) let bereit = false for (let i = 0; i < 60 && !bereit; i++) { try { bereit = (await fetch(`${BASIS}/health`)).ok } catch { /* noch nicht */ } if (!bereit) await schlaf(500) } if (!bereit) { console.log('UNGUELTIG: der Testcontainer antwortet nicht auf /health'); return 2 } const login = await fetch(`${BASIS}/api/dash/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: kennwort }), }) const keks = (login.headers.get('set-cookie') ?? '').split(';')[0] if (!login.ok || !keks) { console.log(`UNGUELTIG: Anmeldung am Dashboard fehlgeschlagen (${login.status})`); return 2 } const dash = (pfad, init = {}) => fetch(`${BASIS}/api/dash${pfad}`, { ...init, headers: { 'Content-Type': 'application/json', Cookie: keks, ...(init.headers ?? {}) } }) const anlegen = async (daten) => (await dash('/tokens', { method: 'POST', body: JSON.stringify(daten) })).json() // Wie der Handy-Token gefesselt werden soll: Praefix, nur Bild, 1 MB. const tHandy = await anlegen({ name: 'presign handy', prefix: 'phone', canDelete: false, arten: ['bild'], maxMb: 1 }) const tPrivat = await anlegen({ name: 'presign privat', prefix: 'fotos', canDelete: false, privat: true }) if (!tHandy.token || !tPrivat.token) { console.log('UNGUELTIG: Tokens liessen sich nicht anlegen'); return 2 } const H = { Authorization: tHandy.token } // nackt, wie Fivemanage und d4rk_phone const holen = async (kopf = H, query = '') => fetch(`${BASIS}/api/v3/file/presigned-url${query}`, { headers: kopf }) const schluessel = async (query = '') => (await (await holen(H, query)).json()).data?.presignedUrl // --- Kontrolle ------------------------------------------------------------- let r = await fetch(`${BASIS}/api/v3/file`, { method: 'POST', headers: H, body: formular(PNG, 'image/png') }) pruefe('K1', 'Kontrolle: /api/v3/file MIT Token laedt hoch', r.status === 200, r.status) if (r.status !== 200) { console.log('UNGUELTIG: der gewoehnliche Weg geht nicht - die Ablehnungen saehen nichts'); return 2 } r = await fetch(`${BASIS}/api/v3/file`, { method: 'POST', body: formular(PNG, 'image/png') }) pruefe('K2', 'Kontrolle: /api/v3/file OHNE Token bleibt 401 (Wache greift noch)', r.status === 401, r.status) // --- Ausstellen -------------------------------------------------------------- r = await holen({}) pruefe('S1', 'Schluessel holen ohne Token: 401', r.status === 401, r.status) r = await holen() const antwort = r.status === 200 ? await r.json() : null const url = antwort?.data?.presignedUrl pruefe('S2', 'Schluessel holen mit Token: Fivemanage-Form data.presignedUrl', r.status === 200 && antwort?.status === 'ok' && typeof url === 'string' && url.startsWith(`${BASIS}/api/v3/file/presigned-url/`), `${r.status} ${url ? 'url da' : 'url fehlt'}`) const bisS = antwort?.data?.expiresAt const jetztS = Date.now() / 1000 pruefe('S3', 'ohne expiresAt: 15 Minuten', typeof bisS === 'number' && Math.abs(bisS - (jetztS + 900)) < 5, `${bisS ? Math.round(bisS - jetztS) : '?'} s`) // --- Hochladen OHNE Token ------------------------------------------------------ r = await fetch(url, { method: 'POST', body: formular(PNG, 'image/png') }) const hoch = r.status === 200 ? await r.json() : null pruefe('U1', 'Hochladen mit Schluessel, OHNE Token', r.status === 200 && hoch?.status === 'ok' && !!hoch?.data?.url, `${r.status}`) const zurueck = hoch?.data?.url ? await fetch(hoch.data.url) : null const bytes = zurueck?.ok ? Buffer.from(await zurueck.arrayBuffer()) : null pruefe('U2', 'die Datei liegt unter phone/ und kommt unveraendert zurueck', !!hoch?.data?.url && hoch.data.url.includes('/f/phone/') && bytes && sha(bytes) === sha(PNG), hoch?.data?.url?.replace(BASIS, '') ?? 'keine url') // --- Einmal ---------------------------------------------------------------------- r = await fetch(url, { method: 'POST', body: formular(PNG, 'image/png') }) pruefe('E1', 'derselbe Schluessel ein zweites Mal: 401', r.status === 401, r.status) r = await fetch(`${BASIS}/api/v3/file/presigned-url/${randomBytes(32).toString('base64url')}`, { method: 'POST', body: formular(PNG, 'image/png') }) pruefe('E2', 'ausgedachter Schluessel: 401', r.status === 401, r.status) // --- Ablauf ------------------------------------------------------------------------ r = await holen(H, `?expiresAt=${Math.floor(Date.now() / 1000) - 10}`) pruefe('A1', 'expiresAt in der Vergangenheit: 400', r.status === 400, r.status) const lang = await (await holen(H, `?expiresAt=${Math.floor(Date.now() / 1000) + 86400}`)).json() pruefe('A2', 'expiresAt in einem Tag: auf 15 Minuten gedeckelt', typeof lang?.data?.expiresAt === 'number' && lang.data.expiresAt - Date.now() / 1000 <= 905, `${Math.round((lang?.data?.expiresAt ?? 0) - Date.now() / 1000)} s`) const kurz = await schluessel(`?expiresAt=${Math.floor(Date.now() / 1000) + 2}`) await schlaf(3500) r = await fetch(kurz, { method: 'POST', body: formular(PNG, 'image/png') }) pruefe('A3', 'abgelaufener Schluessel: 401', r.status === 401, r.status) // --- Fesseln des ausstellenden Tokens ------------------------------------------------ let s = await schluessel() r = await fetch(s, { method: 'POST', body: formular(Buffer.from('ID3'), 'audio/mpeg', 'ton.mp3') }) pruefe('F1', 'Art-Fessel: Ton mit Bild-Token abgelehnt (403)', r.status === 403, r.status) r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png') }) pruefe('F2', '... und der Schluessel ist damit verbraucht (401)', r.status === 401, r.status) s = await schluessel() r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png', 'x.png', 'items/fremd.png') }) pruefe('F3', 'Praefix-Fessel: Pfad ausserhalb phone/ abgelehnt (403)', r.status === 403, r.status) s = await schluessel() r = await fetch(s, { method: 'POST', body: formular(Buffer.alloc(1_300_000, 1), 'image/png', 'gross.png') }) pruefe('F4', 'Groessen-Fessel: 1,3 MB bei 1 MB abgelehnt (413)', r.status === 413, r.status) const svg = Buffer.from('') s = await schluessel() r = await fetch(s, { method: 'POST', body: formular(svg, 'image/svg+xml', 'x.svg') }) const svgUrl = r.status === 200 ? (await r.json()).data?.url : null const svgKopf = svgUrl ? (await fetch(svgUrl)).headers.get('content-security-policy') ?? '' : '' pruefe('F5', 'ein SVG (zaehlt als Bild) kommt mit Sandbox zurueck (#254)', !svgUrl || svgKopf.includes('sandbox'), svgUrl ? (svgKopf || 'keine CSP') : `nicht angenommen (${r.status})`) // --- Token gesperrt, Schluessel offen ------------------------------------------------ s = await schluessel() const liste = await (await dash('/tokens')).json() const id = liste.items?.find((t) => t.name === 'presign handy')?.id const weg = id ? await dash(`/tokens/${id}`, { method: 'DELETE' }) : null r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png') }) pruefe('T1', 'Token geloescht: sein offener Schluessel geht nicht mehr (401)', weg?.ok === true && r.status === 401, `loeschen=${weg?.status} hoch=${r.status}`) // --- Privater Token ---------------------------------------------------------------- r = await holen({ Authorization: tPrivat.token }) pruefe('P1', 'privater Token bekommt keinen Schluessel (403)', r.status === 403, r.status) const fehl = ergebnisse.filter((e) => !e.ok).length console.log(`\n${ergebnisse.length - fehl}/${ergebnisse.length} bestanden`) return fehl ? 1 : 0 } let code = 1 try { code = await main() } catch (err) { console.log(`FEHLER: ${err instanceof Error ? err.message : err}`) } finally { wegraeumen() } process.exit(code)