Aufträge: PDF-Ablage mit Pfadprüfung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98eceaf14c
commit
e9e198e53c
2 files changed
+142
No files matched your search
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Ablage der gesendeten AB-PDFs unter config.auftraege.uploadDir (wird bei jedem Aufruf gelesen):
|
||||
* <uploadDir>/<kundenId>/<auftragId>.pdf, Verzeichnis 0700, Datei 0600. Überschreibt nie (die gesendete Fassung ist
|
||||
* der Beleg). Kennungen und Pfade sind auf [A-Za-z0-9_-] begrenzt (kein Punkt außer vor „pdf“), damit kein „..“ und
|
||||
* kein fremder Pfad möglich ist.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { config } from '../../config.js'
|
||||
import { AuftragError } from './fehler.js'
|
||||
|
||||
const KENNUNG = /^[A-Za-z0-9_-]{1,64}$/
|
||||
const PDF_PFAD = /^[A-Za-z0-9_-]{1,64}\/[A-Za-z0-9_-]{1,64}\.pdf$/
|
||||
|
||||
const nichtGefunden = () => new AuftragError(404, 'PDF nicht gefunden.')
|
||||
const gueltigeKennung = (wert) => typeof wert === 'string' && KENNUNG.test(wert)
|
||||
|
||||
function pfadVon(pdfDatei) {
|
||||
if (typeof pdfDatei !== 'string' || !PDF_PFAD.test(pdfDatei)) throw nichtGefunden()
|
||||
return path.join(config.auftraege.uploadDir, pdfDatei)
|
||||
}
|
||||
|
||||
export async function speicherePdf(kundenId, auftragId, buffer) {
|
||||
if (!gueltigeKennung(kundenId) || !gueltigeKennung(auftragId)) throw new TypeError('Ungültige Kunden- oder Auftrags-ID für die PDF-Ablage.')
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new TypeError('Das PDF muss ein nicht leerer Buffer sein.')
|
||||
const pdfDatei = `${kundenId}/${auftragId}.pdf`
|
||||
const datei = path.join(config.auftraege.uploadDir, pdfDatei)
|
||||
await mkdir(path.dirname(datei), { recursive: true, mode: 0o700 })
|
||||
try {
|
||||
await writeFile(datei, buffer, { flag: 'wx', mode: 0o600 })
|
||||
} catch (err) {
|
||||
if (err?.code === 'EEXIST') throw new AuftragError(409, 'Das PDF dieser Auftragsbestätigung ist schon gespeichert.')
|
||||
// eine angelegte Teildatei nicht liegen lassen
|
||||
await unlink(datei).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
return { pdfDatei, pdfSha256: createHash('sha256').update(buffer).digest('hex') }
|
||||
}
|
||||
|
||||
export async function lesePdf(pdfDatei) {
|
||||
const datei = pfadVon(pdfDatei)
|
||||
try {
|
||||
return await readFile(datei)
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') throw nichtGefunden()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Räumt ein abgelegtes PDF weg, wenn danach etwas scheitert (L3). Fehlt die Datei, ist das folgenlos. */
|
||||
export async function entfernePdf(pdfDatei) {
|
||||
const datei = pfadVon(pdfDatei)
|
||||
try {
|
||||
await unlink(datei)
|
||||
} catch (err) {
|
||||
if (err?.code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// test/auftraegeAblage.test.js
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
// Die Ablage liest ihren Ordner aus config.auftraege.uploadDir: AUFTRAEGE_UPLOAD_DIR muss vor dem ersten Import von config.js stehen.
|
||||
// Die Ablage liegt in einem Unterordner der Wurzel, damit „Köder“-Dateien in der Wurzel einen Ausbruch aus der Ablage (../) sichtbar machen.
|
||||
const PRAEFIX = path.join(os.tmpdir(), 'auftraege-ablage-')
|
||||
const wurzel = mkdtempSync(PRAEFIX)
|
||||
const basis = path.join(wurzel, 'ablage')
|
||||
mkdirSync(basis, { mode: 0o700 })
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = basis
|
||||
after(() => {
|
||||
if (wurzel.startsWith(PRAEFIX)) rmSync(wurzel, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const { speicherePdf, lesePdf, entfernePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
|
||||
const PDF = Buffer.from('%PDF-1.7\n% Auftragsbestätigung\n%%EOF\n')
|
||||
const sha = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||||
const nichtGefunden = (err) => err instanceof AuftragError && err.status === 404 && err.message === 'PDF nicht gefunden.'
|
||||
|
||||
test('speicherePdf legt <kundenId>/<auftragId>.pdf an: Prüfsumme, Rechte 0700 und 0600, Inhalt unverändert', async () => {
|
||||
const ergebnis = await speicherePdf('c1', 'ab1', PDF)
|
||||
assert.deepEqual(ergebnis, { pdfDatei: 'c1/ab1.pdf', pdfSha256: sha(PDF) })
|
||||
assert.match(ergebnis.pdfSha256, /^[0-9a-f]{64}$/)
|
||||
const datei = path.join(basis, 'c1', 'ab1.pdf')
|
||||
assert.deepEqual(readFileSync(datei), PDF)
|
||||
assert.equal(statSync(datei).mode & 0o777, 0o600)
|
||||
assert.equal(statSync(path.join(basis, 'c1')).mode & 0o777, 0o700)
|
||||
assert.deepEqual(await lesePdf('c1/ab1.pdf'), PDF)
|
||||
})
|
||||
|
||||
test('speicherePdf überschreibt nie: die vorhandene Datei bleibt, der zweite Aufruf ist 409', async () => {
|
||||
await speicherePdf('c2', 'ab1', PDF)
|
||||
await assert.rejects(
|
||||
() => speicherePdf('c2', 'ab1', Buffer.from('%PDF-1.7\nanderer Inhalt\n')),
|
||||
(err) => err instanceof AuftragError && err.status === 409,
|
||||
)
|
||||
assert.deepEqual(readFileSync(path.join(basis, 'c2', 'ab1.pdf')), PDF)
|
||||
assert.equal((await speicherePdf('c3', 'ab1', PDF)).pdfDatei, 'c3/ab1.pdf', 'dieselbe AB-ID bei einem anderen Kunden ist eine andere Datei')
|
||||
})
|
||||
|
||||
test('speicherePdf: ungültige Kennungen und Puffer sind Programmfehler und legen nichts an', async () => {
|
||||
const vorher = readdirSync(basis).sort()
|
||||
const kennungen = [['', 'ab1'], ['c1', ''], ['../x', 'ab1'], ['c1', '../ab1'], ['c1/x', 'ab1'], ['c1', 'a.b'], ['c1', 'a'.repeat(65)], [undefined, 'ab1'], ['c1', 5]]
|
||||
for (const [kunde, ab] of kennungen) await assert.rejects(() => speicherePdf(kunde, ab, PDF), TypeError, JSON.stringify([kunde, ab]))
|
||||
for (const buffer of [Buffer.alloc(0), 'text', undefined, null, new Uint8Array(3)]) {
|
||||
await assert.rejects(() => speicherePdf('c9', 'ab9', buffer), TypeError, String(buffer))
|
||||
}
|
||||
assert.deepEqual(readdirSync(basis).sort(), vorher)
|
||||
})
|
||||
|
||||
test('lesePdf: Pfadtricks, fremde Zeichen und falsche Form sind 404; erlaubt sind 64 Zeichen aus A–Z, a–z, 0–9, _ und -', async () => {
|
||||
await speicherePdf('c4', 'ab1', PDF)
|
||||
writeFileSync(path.join(wurzel, 'geheim.pdf'), 'GEHEIM') // neben der Ablage: ein Ausbruch per ../ würde sie lesen
|
||||
const falsch = [
|
||||
'c4/../geheim.pdf', '../geheim.pdf', 'c4/ab1.pdf/../../geheim.pdf', '/c4/ab1.pdf', `${basis}/c4/ab1.pdf`, 'c4\\ab1.pdf',
|
||||
'c4/ab1.PDF', 'c4/ab1.pdf\0', 'c4/ab1.pdf.exe', 'c4/ab 1.pdf', 'c4/ab1', 'ab1.pdf', 'c4/x/ab1.pdf', '', ' ',
|
||||
`${'a'.repeat(65)}/ab1.pdf`, `c4/${'a'.repeat(65)}.pdf`, 'c4/ä.pdf', undefined, null, 42, {},
|
||||
]
|
||||
for (const pdfDatei of falsch) await assert.rejects(() => lesePdf(pdfDatei), nichtGefunden, JSON.stringify(pdfDatei))
|
||||
await assert.rejects(() => lesePdf('c4/gibtsnicht.pdf'), nichtGefunden)
|
||||
const lang = `${'A'.repeat(32)}_${'b'.repeat(31)}`
|
||||
await speicherePdf('K_1-x', lang, PDF)
|
||||
assert.deepEqual(await lesePdf(`K_1-x/${lang}.pdf`), PDF)
|
||||
})
|
||||
|
||||
test('entfernePdf entfernt eine gespeicherte Datei und prüft den Pfad wie lesePdf; danach lässt sich dieselbe AB erneut speichern', async () => {
|
||||
await speicherePdf('c5', 'ab1', PDF)
|
||||
await entfernePdf('c5/ab1.pdf')
|
||||
assert.equal(existsSync(path.join(basis, 'c5', 'ab1.pdf')), false)
|
||||
await entfernePdf('c5/ab1.pdf')
|
||||
await assert.rejects(() => lesePdf('c5/ab1.pdf'), nichtGefunden)
|
||||
writeFileSync(path.join(wurzel, 'geheim2.pdf'), 'x')
|
||||
for (const pfad of ['../geheim2.pdf', 'c5/../geheim2.pdf']) await assert.rejects(() => entfernePdf(pfad), nichtGefunden, pfad)
|
||||
assert.ok(existsSync(path.join(wurzel, 'geheim2.pdf')))
|
||||
assert.equal((await speicherePdf('c5', 'ab1', PDF)).pdfDatei, 'c5/ab1.pdf')
|
||||
})
|
||||
Reference in new issue
Block a user