Sandbox auf ausgelieferte Dateien, die Skript tragen koennen (d4rk-rp-planung#254)
Ein SVG mit <script> lief direkt geoeffnet auf der Herkunft des Dashboards (FILES_HOST leer) - mit dessen Sitzung. Jeder Upload-Token konnte es ablegen, die Arten-Fessel "Bild" laesst SVG durch. Jetzt: Content-Security-Policy "sandbox; default-src 'none'; ..." auf allem ausser Rasterbild, Video und Ton (Positivliste, dateiKopf in storage.ts), auf allen Wegen: /f/, /t/, Dateiwirt, /api/privat. Nicht auf Video und Bild, weil die Sandbox ein direkt geoeffnetes Video anhaelt (readyState 0 statt 4). Gemessen mit tools/csp-pruefen.mjs gegen einen Wegwerf-Container aus diesem Stand, Edge kopflos: vorher lief das Skript (Gegenprobe --erwarte-lauf), jetzt 8/8 - Skript laeuft nicht, SVG wird gezeichnet, PNG und MP4 wie vorher, Teilantwort 206. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6630def7b2
commit
a4e918be8a
4 files changed
+162
-7
No files matched your search
+4
-5
@@ -30,7 +30,7 @@ import { config } from './config.js'
|
|||||||
import { ensureAdmin } from './auth.js'
|
import { ensureAdmin } from './auth.js'
|
||||||
import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js'
|
import { pruneEvents, pruneSessions, pruneVerwaltung, schreibeAbrufe, zaehleAbruf } from './db.js'
|
||||||
import { pruefePlatte, pruefeSicherung } from './meldung.js'
|
import { pruefePlatte, pruefeSicherung } from './meldung.js'
|
||||||
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
|
import { dateiKopf, absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
|
||||||
import { uploadRoutes } from './routes/upload.js'
|
import { uploadRoutes } from './routes/upload.js'
|
||||||
import { fivemanageRoutes } from './routes/fivemanage.js'
|
import { fivemanageRoutes } from './routes/fivemanage.js'
|
||||||
import { freigabeRoutes } from './routes/freigabe.js'
|
import { freigabeRoutes } from './routes/freigabe.js'
|
||||||
@@ -152,9 +152,8 @@ async function serveFile(c: any, rawPath: string) {
|
|||||||
ETag: etag,
|
ETag: etag,
|
||||||
'Last-Modified': info.mtime.toUTCString(),
|
'Last-Modified': info.mtime.toUTCString(),
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
// Kein Verlassen auf den Dateinamen: der Browser soll nicht raten,
|
// nosniff und die Sandbox gegen Skript-SVGs (#254) - storage.ts, dateiKopf.
|
||||||
// was er da vor sich hat.
|
...dateiKopf(typ),
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung.
|
// GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung.
|
||||||
@@ -282,7 +281,7 @@ app.get('/t/*', async (c) => {
|
|||||||
// Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen
|
// Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen
|
||||||
// wird, und dann soll das Dashboard es sehen.
|
// wird, und dann soll das Dashboard es sehen.
|
||||||
'Cache-Control': 'public, max-age=60',
|
'Cache-Control': 'public, max-age=60',
|
||||||
'X-Content-Type-Options': 'nosniff',
|
...dateiKopf('image/webp'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
|
|||||||
import {
|
import {
|
||||||
PathError,
|
PathError,
|
||||||
checkPathUnter,
|
checkPathUnter,
|
||||||
|
dateiKopf,
|
||||||
kannVorschau,
|
kannVorschau,
|
||||||
mimeFor,
|
mimeFor,
|
||||||
privatPfad,
|
privatPfad,
|
||||||
@@ -186,8 +187,8 @@ privatRoutes.get('/datei/:pfad{.+}', async (c) => {
|
|||||||
return c.body(createReadStream(datei) as any, 200, {
|
return c.body(createReadStream(datei) as any, 200, {
|
||||||
'Content-Type': vorschau ? 'image/webp' : row.mime,
|
'Content-Type': vorschau ? 'image/webp' : row.mime,
|
||||||
'Content-Length': String(groesse),
|
'Content-Length': String(groesse),
|
||||||
// Nie in einem geteilten Zwischenspeicher, und der Browser rät den Typ nicht.
|
// Nie in einem geteilten Zwischenspeicher; nosniff und Sandbox (#254) aus storage.ts.
|
||||||
'Cache-Control': 'private, no-store',
|
'Cache-Control': 'private, no-store',
|
||||||
'X-Content-Type-Options': 'nosniff',
|
...dateiKopf(vorschau ? 'image/webp' : row.mime),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -74,6 +74,39 @@ export function checkPathUnter(raw: string, basis: string): string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DIE KOPFZEILEN AUF JEDER AUSGELIEFERTEN DATEI (d4rk-rp-planung#254).
|
||||||
|
*
|
||||||
|
* Ein SVG kann <script> tragen, und es wird als image/svg+xml ausgeliefert
|
||||||
|
* -- direkt geoeffnet lief das Skript auf DIESER Herkunft, also da, wo das
|
||||||
|
* Dashboard mit seiner Sitzung wohnt (FILES_HOST ist leer). Gemessen
|
||||||
|
* 19.10.2026 mit tools/csp-pruefen.mjs: vorher lief es.
|
||||||
|
*
|
||||||
|
* `sandbox` schaltet Skripte im Dokument ab, auch im SVG. Fuer <img>, <video>
|
||||||
|
* und CSS-Hintergruende anderer Seiten aendert sich nichts: die Policy einer
|
||||||
|
* Antwort gilt nur, wenn sie SELBST das Dokument ist.
|
||||||
|
*
|
||||||
|
* NICHT AUF RASTERBILD, VIDEO UND TON: `sandbox` hielt ein direkt geoeffnetes
|
||||||
|
* Video an (readyState 0 statt 4, gemessen 19.10.2026 - auch mit media-src *,
|
||||||
|
* es liegt an der Sandbox selbst). Diese Arten fuehren ohnehin kein Skript aus,
|
||||||
|
* und nosniff verbietet dem Browser, sie umzudeuten. Die Sandbox bekommt
|
||||||
|
* deshalb ALLES ANDERE - eine Positivliste, keine Negativliste: eine neue Art,
|
||||||
|
* an die niemand gedacht hat, ist damit geschuetzt statt offen.
|
||||||
|
*
|
||||||
|
* EINE Stelle fuer alle Wege, die Bytes ausliefern (/f/, /t/, der Dateiwirt,
|
||||||
|
* /api/privat). Ein Weg ohne diese Kopfzeilen waere die Luecke von vorn.
|
||||||
|
*/
|
||||||
|
const OHNE_SANDBOX = /^(image\/(png|jpeg|gif|webp|avif)|video\/|audio\/)/i
|
||||||
|
const SANDBOX = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||||
|
|
||||||
|
/** Die Sicherheits-Kopfzeilen fuer eine Datei dieser Art (Content-Type). */
|
||||||
|
export function dateiKopf(typ: string): Record<string, string> {
|
||||||
|
return OHNE_SANDBOX.test(typ)
|
||||||
|
// Kein Verlassen auf den Dateinamen: der Browser soll nicht raten, was er vor sich hat.
|
||||||
|
? { 'X-Content-Type-Options': 'nosniff' }
|
||||||
|
: { 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': SANDBOX }
|
||||||
|
}
|
||||||
|
|
||||||
const MIME: Record<string, string> = {
|
const MIME: Record<string, string> = {
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Prueft, dass ausgelieferte Dateien kein Skript ausfuehren (d4rk-rp-planung#254)
|
||||||
|
// und trotzdem noch als Bild und Video funktionieren.
|
||||||
|
//
|
||||||
|
// node tools/csp-pruefen.mjs <basis> <svg-pfad> <png-pfad> <mp4-pfad>
|
||||||
|
// node tools/csp-pruefen.mjs http://localhost:9188 test/skript.svg test/bild.png test/clip.mp4
|
||||||
|
//
|
||||||
|
// Gemessen wird im ECHTEN Browser (Edge kopflos, DevTools-Protokoll), nicht an
|
||||||
|
// der Kopfzeile allein: ob ein SVG-Skript laeuft, entscheidet der Browser.
|
||||||
|
// Das SVG setzt, wenn sein Skript laeuft, data-lief="ja" am Wurzelelement.
|
||||||
|
//
|
||||||
|
// GEGENPROBE: gegen einen Stand OHNE die Kopfzeile muss "svgSkriptLief" true
|
||||||
|
// sein - sonst sieht diese Pruefung nichts und ihr "false" hiesse nichts.
|
||||||
|
// Mit --erwarte-lauf prueft sie genau das (Rueckgabe 0, wenn das Skript LIEF).
|
||||||
|
//
|
||||||
|
// Das Edge-Profil: frisch je Lauf, aber mit der `Local State` einer Vorlage -
|
||||||
|
// ein fremdes Profil kostet sonst eine Fehlanmeldung am Windows-Konto, zehn
|
||||||
|
// davon sperren es (gemessen 12.10.2026, d4rk_mdt/frontend/tools/cdp.mjs,
|
||||||
|
// dort steht dieselbe Logik und warum).
|
||||||
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
const EDGE = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'
|
||||||
|
const VORLAGE = join(process.env.LOCALAPPDATA ?? tmpdir(), 'd4rk-messen', 'edge-vorlage', 'Local State')
|
||||||
|
const [basis, svg, png, mp4] = process.argv.slice(2).filter((a) => !a.startsWith('--'))
|
||||||
|
const erwarteLauf = process.argv.includes('--erwarte-lauf')
|
||||||
|
if (!basis || !svg || !png || !mp4) {
|
||||||
|
console.error('Aufruf: node tools/csp-pruefen.mjs <basis> <svg-pfad> <png-pfad> <mp4-pfad> [--erwarte-lauf]')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const schlaf = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
// 1. Die Kopfzeilen, ohne Browser.
|
||||||
|
const kopf = {}
|
||||||
|
for (const [art, pfad] of [['svg', svg], ['png', png], ['mp4', mp4]]) {
|
||||||
|
const r = await fetch(`${basis}/f/${pfad}`)
|
||||||
|
kopf[art] = { status: r.status, typ: r.headers.get('content-type'), csp: r.headers.get('content-security-policy') }
|
||||||
|
await r.arrayBuffer()
|
||||||
|
}
|
||||||
|
const teil = await fetch(`${basis}/f/${mp4}`, { headers: { Range: 'bytes=0-99' } })
|
||||||
|
kopf.mp4Teil = { status: teil.status, csp: teil.headers.get('content-security-policy') }
|
||||||
|
await teil.arrayBuffer()
|
||||||
|
|
||||||
|
// 2. Im Browser.
|
||||||
|
const profil = mkdtempSync(join(tmpdir(), 'media-csp-'))
|
||||||
|
try { copyFileSync(VORLAGE, join(profil, 'Local State')) } catch { /* noch keine Vorlage */ }
|
||||||
|
const port = 9398
|
||||||
|
const edge = spawn(EDGE, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
||||||
|
`--user-data-dir=${profil}`, `--remote-debugging-port=${port}`, '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' })
|
||||||
|
|
||||||
|
let ws = null
|
||||||
|
try {
|
||||||
|
let url = null
|
||||||
|
for (let i = 0; i < 60 && !url; i++) {
|
||||||
|
try { url = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find((t) => t.type === 'page')?.webSocketDebuggerUrl } catch { /* noch nicht oben */ }
|
||||||
|
if (!url) await schlaf(250)
|
||||||
|
}
|
||||||
|
if (!url) throw new Error('DevTools-Endpunkt kam nicht hoch')
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
await new Promise((ok, weg) => { ws.onopen = ok; ws.onerror = () => weg(new Error('WebSocket zu')) })
|
||||||
|
let nr = 0
|
||||||
|
const offen = new Map()
|
||||||
|
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && offen.has(m.id)) { offen.get(m.id)(m); offen.delete(m.id) } }
|
||||||
|
// Jeder Aufruf mit Zeitgrenze: ohne sie blieb ein unbeantworteter Aufruf ewig
|
||||||
|
// stehen und der Lauf hing ohne jede Ausgabe (gemessen 19.10.2026).
|
||||||
|
const cdp = (method, params = {}) => new Promise((ok) => {
|
||||||
|
const id = ++nr
|
||||||
|
offen.set(id, ok)
|
||||||
|
ws.send(JSON.stringify({ id, method, params }))
|
||||||
|
setTimeout(() => { if (offen.has(id)) { offen.delete(id); ok({ zeit: true }) } }, 10000)
|
||||||
|
})
|
||||||
|
const werte = async (ausdruck) => {
|
||||||
|
const r = await cdp('Runtime.evaluate', { expression: ausdruck, returnByValue: true, awaitPromise: true })
|
||||||
|
if (r.zeit) return 'ZEIT'
|
||||||
|
if (r.result?.exceptionDetails) return 'FEHLER: ' + (r.result.exceptionDetails.exception?.description ?? r.result.exceptionDetails.text)
|
||||||
|
return r.result?.result?.value
|
||||||
|
}
|
||||||
|
const oeffne = async (pfad, ms) => { console.error(`oeffne ${pfad}`); await cdp('Page.navigate', { url: `${basis}/f/${pfad}` }); await schlaf(ms) }
|
||||||
|
await cdp('Page.enable'); await cdp('Runtime.enable')
|
||||||
|
|
||||||
|
await oeffne(svg, 1200)
|
||||||
|
const svgSkriptLief = (await werte(`document.documentElement.getAttribute('data-lief')`)) === 'ja'
|
||||||
|
const svgGezeichnet = (await werte(`!!document.querySelector('rect')`)) === true
|
||||||
|
|
||||||
|
await oeffne(png, 1000)
|
||||||
|
const pngBreite = await werte(`document.querySelector('img')?.naturalWidth ?? null`)
|
||||||
|
|
||||||
|
// OHNE Promise und Timer im Dokument gelesen: in einer Antwort mit `sandbox`
|
||||||
|
// laufen keine Skript-Timer - ein awaitPromise mit setTimeout kam dort nie
|
||||||
|
// zurueck (gemessen 19.10.2026, die erste Fassung hing daran). Gewartet wird
|
||||||
|
// hier auf der Node-Seite.
|
||||||
|
await oeffne(mp4, 3000)
|
||||||
|
const video = await werte(`(() => { const v = document.querySelector('video'); return v ? { da: true, breite: v.videoWidth, hoehe: v.videoHeight, bereit: v.readyState } : { da: false } })()`)
|
||||||
|
|
||||||
|
// Sandbox NUR auf dem, was Skript tragen kann (storage.ts, dateiKopf): auf
|
||||||
|
// PNG und MP4 haelt sie das direkt geoeffnete Video an (gemessen 19.10.2026).
|
||||||
|
const csp = (k) => typeof k?.csp === 'string' && k.csp.includes('sandbox')
|
||||||
|
const pruefungen = erwarteLauf
|
||||||
|
? { svgSkriptLief: svgSkriptLief === true }
|
||||||
|
: {
|
||||||
|
svgSkriptLiefNicht: svgSkriptLief === false,
|
||||||
|
svgGezeichnet,
|
||||||
|
svgCsp: csp(kopf.svg), pngOhneSandbox: !csp(kopf.png), mp4OhneSandbox: !csp(kopf.mp4) && !csp(kopf.mp4Teil),
|
||||||
|
pngBild: pngBreite === 1,
|
||||||
|
videoSpielt: video?.da === true && video.breite > 0,
|
||||||
|
mp4Teil206: kopf.mp4Teil.status === 206,
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ kopf, svgSkriptLief, svgGezeichnet, pngBreite, video, pruefungen }, null, 1))
|
||||||
|
const rot = Object.entries(pruefungen).filter(([, v]) => !v).map(([k]) => k)
|
||||||
|
console.log(rot.length ? `ROT: ${rot.join(', ')}` : `csp-pruefen: ${Object.keys(pruefungen).length}/${Object.keys(pruefungen).length} ok${erwarteLauf ? ' (Gegenprobe: Skript lief)' : ''}`)
|
||||||
|
process.exitCode = rot.length ? 1 : 0
|
||||||
|
} finally {
|
||||||
|
try { ws?.close() } catch { /* zu */ }
|
||||||
|
spawnSync('taskkill', ['/T', '/F', '/PID', String(edge.pid)], { stdio: 'ignore' })
|
||||||
|
await schlaf(800)
|
||||||
|
try {
|
||||||
|
const inhalt = readFileSync(join(profil, 'Local State'), 'utf8'); JSON.parse(inhalt)
|
||||||
|
mkdirSync(dirname(VORLAGE), { recursive: true }); writeFileSync(VORLAGE, inhalt)
|
||||||
|
} catch { /* keine brauchbare Datei */ }
|
||||||
|
for (let i = 0; i < 20; i++) { try { rmSync(profil, { recursive: true, force: true }); break } catch { await schlaf(500) } }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user