diff --git a/nextjs/app/%5Frequest/[id]/upload/route.js b/nextjs/app/%5Frequest/[id]/upload/route.js index 40a30a8..df953d1 100644 --- a/nextjs/app/%5Frequest/[id]/upload/route.js +++ b/nextjs/app/%5Frequest/[id]/upload/route.js @@ -1,8 +1,6 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; import { NextResponse } from 'next/server'; @@ -16,6 +14,7 @@ import { import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js'; import { safeBaseName } from '@/src/lib/files.js'; import { sendUploadRequestCompletedMail } from '@/src/lib/mailer.js'; +import { streamMultipartToPath } from '@/src/lib/multipart.js'; import { getRequestMeta } from '@/src/lib/security.js'; export const runtime = 'nodejs'; @@ -114,39 +113,6 @@ function createRandomId() { return toBase32(crypto.randomBytes(5)); } -function uploadedFileFromForm(formData, fieldName = 'file') { - const candidate = formData.get(fieldName); - if (!candidate || typeof candidate === 'string') { - return null; - } - if (typeof candidate.arrayBuffer !== 'function') { - return null; - } - return candidate; -} - -async function writeUploadedFile(uploadedFile, targetPath) { - try { - if (typeof uploadedFile.stream === 'function') { - await pipeline(Readable.fromWeb(uploadedFile.stream()), fs.createWriteStream(targetPath)); - } else { - const buffer = Buffer.from(await uploadedFile.arrayBuffer()); - await fs.promises.writeFile(targetPath, buffer); - } - } catch (error) { - await fs.promises.rm(targetPath, { force: true }).catch(() => undefined); - throw error; - } - - const declaredSize = Number(uploadedFile.size || 0); - if (Number.isFinite(declaredSize) && declaredSize >= 0) { - return declaredSize; - } - - const stat = await fs.promises.stat(targetPath); - return stat.size; -} - export async function POST(request, { params }) { await runCleanupIfNeeded(); @@ -157,13 +123,6 @@ export async function POST(request, { params }) { return new NextResponse('Ungültige Anfrage-ID', { status: 400 }); } - let formData; - try { - formData = await request.formData(); - } catch { - return redirectToRequest(requestId, { error: 'Ungültige Formulardaten.' }); - } - const uploadRequest = await get( `SELECT id, owner, note, expires_at, completed_at FROM upload_requests @@ -184,20 +143,6 @@ export async function POST(request, { params }) { return redirectToRequest(requestId, { error: 'Diese Anfrage ist bereits abgelaufen.' }); } - const uploadedFile = uploadedFileFromForm(formData, 'file'); - if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) { - return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' }); - } - - if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) { - return redirectToRequest(requestId, { - error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, - }); - } - - const fulfilledBy = String(formData.get('fulfilledBy') || '').trim().slice(0, 200); - const originalName = safeBaseName(uploadedFile.name, 'upload'); - let storedName = ''; let storedPath = ''; for (let attempts = 0; attempts < 12; attempts += 1) { @@ -216,12 +161,33 @@ export async function POST(request, { params }) { return redirectToRequest(requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' }); } + let parsed; + try { + parsed = await streamMultipartToPath(request, { + fileFieldName: 'file', + targetPath: storedPath, + maxFileBytes: maxUploadBytes, + }); + } catch (error) { + if (error && error.message === 'file-too-large') { + return redirectToRequest(requestId, { + error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, + }); + } + if (error && error.message === 'missing-file') { + return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' }); + } + return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' }); + } + + const fulfilledBy = String(parsed.fields.fulfilledBy || '').trim().slice(0, 200); + const originalName = safeBaseName(parsed.file.filename, 'upload'); + const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000); let uploadId = null; - let sizeBytes = 0; + const sizeBytes = parsed.file.sizeBytes; try { - sizeBytes = await writeUploadedFile(uploadedFile, storedPath); const insertResult = await run( `INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, @@ -229,6 +195,7 @@ export async function POST(request, { params }) { ); uploadId = insertResult.lastID; } catch { + await fs.promises.rm(storedPath, { force: true }).catch(() => undefined); return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' }); } diff --git a/nextjs/app/manage/api/upload/route.js b/nextjs/app/manage/api/upload/route.js index 8441d15..c5fb8a6 100644 --- a/nextjs/app/manage/api/upload/route.js +++ b/nextjs/app/manage/api/upload/route.js @@ -1,9 +1,6 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; - import { NextResponse } from 'next/server'; import { @@ -14,8 +11,9 @@ import { uploadTtlSeconds, } from '@/src/lib/config.js'; import { logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js'; +import { streamMultipartToPath } from '@/src/lib/multipart.js'; import { safeBaseName } from '@/src/lib/files.js'; -import { getAuthenticatedUser, getRequestMeta, verifyCsrf } from '@/src/lib/security.js'; +import { getAuthenticatedUser, getRequestMeta, verifyCsrfToken } from '@/src/lib/security.js'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -55,39 +53,6 @@ function createRandomId() { return toBase32(crypto.randomBytes(5)); } -async function writeUploadedFile(uploadedFile, targetPath) { - try { - if (typeof uploadedFile.stream === 'function') { - await pipeline(Readable.fromWeb(uploadedFile.stream()), fs.createWriteStream(targetPath)); - } else { - const buffer = Buffer.from(await uploadedFile.arrayBuffer()); - await fs.promises.writeFile(targetPath, buffer); - } - } catch (error) { - await fs.promises.rm(targetPath, { force: true }).catch(() => undefined); - throw error; - } - - const declaredSize = Number(uploadedFile.size || 0); - if (Number.isFinite(declaredSize) && declaredSize >= 0) { - return declaredSize; - } - - const stat = await fs.promises.stat(targetPath); - return stat.size; -} - -function uploadedFileFromForm(formData, fieldName = 'file') { - const candidate = formData.get(fieldName); - if (!candidate || typeof candidate === 'string') { - return null; - } - if (typeof candidate.arrayBuffer !== 'function') { - return null; - } - return candidate; -} - function dashboardHref(params = {}) { const query = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { @@ -140,32 +105,7 @@ export async function POST(request) { return errorResponse(request, 'Nicht angemeldet.', 401); } - let formData; - try { - formData = await request.formData(); - } catch { - return errorResponse(request, 'Ungültige Formulardaten.', 400); - } - - try { - await verifyCsrf(formData); - } catch { - return errorResponse(request, 'CSRF-Prüfung fehlgeschlagen.', 403); - } - - const uploadedFile = uploadedFileFromForm(formData, 'file'); - if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) { - return errorResponse(request, 'Keine Datei hochgeladen.', 400); - } - - if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) { - return errorResponse(request, `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, 413); - } - const now = Date.now(); - const originalName = safeBaseName(uploadedFile.name, 'upload'); - const retentionSeconds = parseHours(formData.get('retentionHours'), uploadTtlSeconds); - const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds); let storedName = ''; let storedPath = ''; @@ -186,8 +126,36 @@ export async function POST(request) { return errorResponse(request, 'Upload-ID konnte nicht erzeugt werden.', 500); } + let parsed; try { - const sizeBytes = await writeUploadedFile(uploadedFile, storedPath); + parsed = await streamMultipartToPath(request, { + fileFieldName: 'file', + targetPath: storedPath, + maxFileBytes: maxUploadBytes, + }); + } catch (error) { + if (error && error.message === 'file-too-large') { + return errorResponse(request, `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, 413); + } + if (error && error.message === 'missing-file') { + return errorResponse(request, 'Keine Datei hochgeladen.', 400); + } + return errorResponse(request, 'Upload fehlgeschlagen.', 500); + } + + try { + await verifyCsrfToken(parsed.fields.csrfToken); + } catch { + await fs.promises.rm(storedPath, { force: true }).catch(() => undefined); + return errorResponse(request, 'CSRF-Prüfung fehlgeschlagen.', 403); + } + + const retentionSeconds = parseHours(parsed.fields.retentionHours, uploadTtlSeconds); + const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds); + const originalName = safeBaseName(parsed.file.filename, 'upload'); + + try { + const sizeBytes = parsed.file.sizeBytes; await run( `INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at) @@ -206,10 +174,11 @@ export async function POST(request) { await logEvent( 'upload', user.username, - { name: storedName, size: Number(uploadedFile.size || 0) }, + { name: storedName, size: sizeBytes }, await getRequestMeta() ); } catch { + await fs.promises.rm(storedPath, { force: true }).catch(() => undefined); return errorResponse(request, 'Upload fehlgeschlagen.', 500); } diff --git a/nextjs/package-lock.json b/nextjs/package-lock.json index f31111c..65ced33 100644 --- a/nextjs/package-lock.json +++ b/nextjs/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "bcryptjs": "^2.4.3", + "busboy": "^1.6.0", "jsonwebtoken": "^9.0.3", "next": "^16.2.1", "nodemailer": "^8.0.4", @@ -931,6 +932,17 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/cacache": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", @@ -2348,6 +2360,14 @@ "node": ">= 8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/nextjs/package.json b/nextjs/package.json index b1cbdaf..03c1de1 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "bcryptjs": "^2.4.3", + "busboy": "^1.6.0", "jsonwebtoken": "^9.0.3", "next": "^16.2.1", "nodemailer": "^8.0.4", diff --git a/nextjs/src/lib/multipart.js b/nextjs/src/lib/multipart.js new file mode 100644 index 0000000..7e3fd4e --- /dev/null +++ b/nextjs/src/lib/multipart.js @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import { Readable } from 'node:stream'; + +import Busboy from 'busboy'; + +export async function streamMultipartToPath(request, options) { + const { fileFieldName = 'file', targetPath, maxFileBytes = 0 } = options; + + if (!request.body) { + throw new Error('missing-body'); + } + + return new Promise((resolve, reject) => { + const limits = { files: 1, fields: 20 }; + if (maxFileBytes > 0) { + limits.fileSize = maxFileBytes; + } + + const parser = Busboy({ headers: Object.fromEntries(request.headers), limits }); + const fields = {}; + let uploadInfo = null; + let writeStream = null; + let writeFinished = false; + let writeError = null; + let hitSizeLimit = false; + + function fail(error) { + if (writeStream) { + writeStream.destroy(); + } + fs.promises.rm(targetPath, { force: true }).catch(() => undefined).finally(() => reject(error)); + } + + parser.on('field', (name, value) => { + fields[name] = value; + }); + + parser.on('file', (name, file, info) => { + if (name !== fileFieldName || uploadInfo) { + file.resume(); + return; + } + + writeStream = fs.createWriteStream(targetPath, { flags: 'wx' }); + uploadInfo = { + filename: info.filename || '', + mimeType: info.mimeType || '', + sizeBytes: 0, + }; + + file.on('data', (chunk) => { + uploadInfo.sizeBytes += chunk.length; + }); + + file.on('limit', () => { + hitSizeLimit = true; + }); + + file.on('error', (error) => { + writeError = error; + }); + + writeStream.on('error', (error) => { + writeError = error; + }); + + writeStream.on('close', () => { + writeFinished = true; + }); + + file.pipe(writeStream); + }); + + parser.on('error', (error) => { + fail(error); + }); + + parser.on('finish', () => { + const complete = () => { + if (writeError) { + fail(writeError); + return; + } + if (hitSizeLimit) { + fail(new Error('file-too-large')); + return; + } + if (!uploadInfo) { + fail(new Error('missing-file')); + return; + } + resolve({ fields, file: uploadInfo }); + }; + + if (!writeStream) { + complete(); + return; + } + + if (writeFinished) { + complete(); + return; + } + + writeStream.on('close', complete); + }); + + Readable.fromWeb(request.body).pipe(parser); + }); +} diff --git a/nextjs/src/lib/security.js b/nextjs/src/lib/security.js index 1380304..ac2d42f 100644 --- a/nextjs/src/lib/security.js +++ b/nextjs/src/lib/security.js @@ -90,11 +90,25 @@ export async function verifyCsrf(formData) { providedToken = String(headerStore.get('x-csrf-token') || ''); } + await verifyCsrfTokenValue(expectedToken, providedToken); +} + +async function verifyCsrfTokenValue(expectedToken, providedToken) { if (!expectedToken || !providedToken || expectedToken !== providedToken) { throw new Error('csrf-token-mismatch'); } } +export async function verifyCsrfToken(token) { + await verifySameOrigin(); + + const cookieStore = await cookies(); + const expectedToken = cookieStore.get(csrfCookieName)?.value || ''; + const providedToken = String(token || ''); + + await verifyCsrfTokenValue(expectedToken, providedToken); +} + export async function getRequestMeta() { const headerStore = await headers(); const forwardedFor = firstForwardedPart(headerStore.get('x-forwarded-for'));