Compare commits

..
4 Commits
Author SHA1 Message Date
Nick Szaunig be5fb32a4a perf: Admin-Bildvorschauen ueber next/image statt als rohes <img>
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Die Vorschaukacheln in admin/inhalte, admin/produkte und admin/ernten
luden das vollstaendig gespeicherte Bild, um es auf 80x80 bzw. in einer
Vierer-Rasterkachel darzustellen. Bei einem Produktformular mit mehreren
Bildern summierte sich das zu mehreren Megabyte allein fuer Miniaturen.

Der urspruengliche Kommentar ("next/image lohnt hier nicht") stimmte, als
Uploads klein waren; gespeichert wird inzwischen bis MAX_IMAGE_DIMENSION
Kantenlaenge. Beide Felder rendern die Vorschau jetzt ueber next/image
mit passendem sizes, der Optimierer liefert damit genau die gebrauchte
Groesse. Layout und Bedienung bleiben unveraendert.
2026-09-13 23:16:35 +02:00
Nick Szaunig cd8e307faf perf: hochgeladene Bilder und Videos schnell und vollstaendig ausliefern
Vier Befunde aus der Pruefung der Upload-Kette, alle am gemeinsamen
Datenpfad lib/uploads.ts und app/uploads/[...path]/route.ts:

1. Kein Range-Support. Die Ausliefer-Route beantwortete auch einen
   Range-Request mit einer vollstaendigen 200-Antwort. Ein <video> laedt
   sein Material grundsaetzlich per Range; iOS-Safari schickt zuerst die
   Probe "bytes=0-1" und spielt bei einer 200-Antwort gar nicht ab.
   Springen im Hero-Video war ebenfalls unmoeglich. Die Route beantwortet
   Bereichsanfragen jetzt mit 206 samt Content-Range, meldet
   Accept-Ranges und antwortet auf einen unerfuellbaren Bereich mit 416.

2. Ganze Datei im Speicher. readFile() las jede Datei vollstaendig in den
   RAM — bei einem Hero-Video bis MAX_UPLOAD_VIDEO_BYTES pro
   gleichzeitigem Zugriff. Ersetzt durch einen Stream von der Platte,
   inklusive Content-Length (vorher chunked, ohne Groessenangabe).

3. Keine bedingten Anfragen. Ohne ETag lud der Browser bei jedem Reload
   den vollen Bildinhalt erneut. ETag aus Groesse + Aenderungszeit, 304
   auf If-None-Match.

4. Bilder wurden in Originalaufloesung gespeichert. Ein 24-MP-Foto lag
   mit 6000x4000 und 9 MB auf dem Volume, und next/image musste dieses
   Bild fuer JEDE ausgelieferte Breite komplett neu dekodieren.
   reencodeImage() begrenzt jetzt auf MAX_IMAGE_DIMENSION (2560px lange
   Kante, Seitenverhaeltnis erhalten, kleinere Bilder werden nie
   hochskaliert).

Zusaetzlich MAX_UPLOAD_BYTES von 8 auf 25 MB angehoben: 8 MB lagen unter
dem, was aktuelle Handykameras routinemaessig erzeugen — ein normales
Foto brach mit "Die Datei ist zu gross" ab, obwohl es serverseitig
ohnehin verkleinert wird. Gegen eine Dekompressionsbombe (kleine Datei,
die sich zu Milliarden Pixeln entpackt) bekommt sharp mit
MAX_INPUT_PIXELS eine ausdrueckliche Obergrenze von 100 MP, die die
Byte-Grenze allein nicht abdeckt.

Gemessen an einem 10,17-MB-Foto mit 6000x4000: vorher mit 413 abgelehnt,
jetzt als 2560x1707 / 1,88 MB gespeichert; next/image kalt fuer 640px von
180 ms auf 65 ms.

Der Kommentar zur proxy-body-size in k8s/06-ingress.yaml nennt jetzt das
neue Bild-Limit; die Annotation selbst (110m) deckt es unveraendert ab.
2026-09-13 23:16:23 +02:00
Nick Szaunig 0920d33721 fix: Video-Upload scheiterte, weil ffmpeg-static mitgebuendelt wurde
Jeder Video-Upload ohne System-ffmpeg im PATH — also jede lokale
Entwicklungsumgebung unter Windows/macOS — endete mit 400 "Das Video
konnte nicht verarbeitet werden.". Im Server-Log stand der eigentliche
Grund:

  spawn \ROOT\node_modules\.pnpm\[email protected]\...\ffmpeg.exe ENOENT

ffmpeg-static exportiert den Pfad zu seiner mitgelieferten Binary
__dirname-relativ. Wird das Paket vom Bundler mitgebuendelt, zeigt sein
__dirname auf einen virtuellen Bundle-Pfad ("/ROOT/...") statt auf das
echte Verzeichnis in node_modules — genau dieselbe Ursache, aus der
pdfkit hier schon als serverExternalPackage gefuehrt wird.

ffmpeg-static daher ebenfalls in serverExternalPackages aufgenommen; der
Kommentar erklaert die Ursache jetzt einmal fuer beide Pakete. Der
Standalone-Build kopiert das Paket damit unveraendert nach
node_modules (verifiziert).
2026-09-13 23:15:39 +02:00
Nick Szaunig abdebfc477 fix: Admin-Upload scheiterte an der CSRF-Pruefung (403 "Ungueltige Anfrage.")
Die Same-Origin-Pruefung verglich den Origin-Header gegen
new URL(request.url).host. Next.js rekonstruiert request.url aber aus der
internen Bind-Adresse des Servers, NICHT aus dem Host-Header, den der
Browser tatsaechlich adressiert hat — reproduzierbar liefert request.url
"localhost:3999", waehrend der Browser "127.0.0.1:3999" aufgerufen hat.

Damit schlug jeder Admin-Upload mit 403 fehl, sobald die Seite unter einem
anderen Namen als dieser internen Adresse erreicht wurde: oeffentliche
Domain hinter dem Reverse-Proxy, LAN-IP, oder schlicht 127.0.0.1 statt
localhost. Der 2025 nachgeruestete Fallback auf NEXT_PUBLIC_SITE_URL half
nur, wenn diese Variable gesetzt ist und exakt passt — in .env fehlt sie.

Der Origin wird jetzt gegen alle plausiblen eigenen Hosts geprueft, in
dieser Reihenfolge: X-Forwarded-Host (vom eigenen Proxy gesetzt, Caddy
verwirft einen vom Client mitgeschickten Wert), Host (was der Browser
adressiert hat — derselbe Vergleich, den Next.js fuer Server Actions
intern anstellt), request.url und zuletzt NEXT_PUBLIC_SITE_URL.

Der CSRF-Schutz bleibt dabei vollstaendig: eine fremde Seite kann bei
einem Cross-Site-Request weder Host noch X-Forwarded-Host bestimmen — den
Host setzt der Browser aus der Ziel-URL, eigene Header liessen sich nur
per fetch() setzen, was einen CORS-Preflight ausloest, den dieser
Endpunkt nicht beantwortet. Ein gefaelschter POST traegt also weiterhin
zwangslaeufig einen fremden Origin und wird abgewiesen (durch Test und
manuelle Gegenprobe belegt).
2026-09-13 23:15:28 +02:00
10 changed files with 518 additions and 73 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ metadata:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
# ingress-nginx laesst ohne diese Annotation standardmaessig nur 1 MB pro # ingress-nginx laesst ohne diese Annotation standardmaessig nur 1 MB pro
# Request durch — weit unter dem Bild- (8 MB, lib/uploads.ts # Request durch — weit unter dem Bild- (25 MB, lib/uploads.ts
# MAX_UPLOAD_BYTES) und erst recht dem Video-Limit (100 MB, lib/uploads.ts # MAX_UPLOAD_BYTES) und erst recht dem Video-Limit (100 MB, lib/uploads.ts
# MAX_UPLOAD_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht # MAX_UPLOAD_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht
# nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch # nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch
+15 -6
View File
@@ -9,12 +9,21 @@ const nextConfig: NextConfig = {
// wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt. // wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt.
output: "standalone", output: "standalone",
// pdfkit laedt seine Schriftart-Metadaten (.afm-Dateien) zur Laufzeit ueber // Pakete, die zur Laufzeit eigene Dateien ueber einen __dirname-relativen
// einen eigenen __dirname-relativen Pfad. Wird das Paket vom Bundler // Pfad nachladen. Werden sie vom Bundler (Turbopack/Webpack) mitgebuendelt,
// (Turbopack/Webpack) mitgebuendelt, geht dieser Pfad verloren (ENOENT). // zeigt ihr __dirname auf einen virtuellen Bundle-Pfad ("/ROOT/...") statt
// "serverExternalPackages" laesst Next.js es stattdessen ganz normal per // auf das echte Verzeichnis in node_modules — der Zugriff schlaegt dann mit
// require() aus node_modules laden. // ENOENT fehl. "serverExternalPackages" laesst Next.js sie stattdessen ganz
serverExternalPackages: ["pdfkit"], // normal per require() aus node_modules laden.
//
// - pdfkit: laedt seine Schriftart-Metadaten (.afm-Dateien).
// - ffmpeg-static: exportiert den Pfad zur mitgelieferten ffmpeg-Binary
// (siehe lib/video-transcode.ts). Gebuendelt lieferte es
// "\ROOT\node_modules\...\ffmpeg.exe" -> jeder Video-Upload ohne
// System-ffmpeg im PATH (also jede lokale Entwicklungsumgebung unter
// Windows/macOS) scheiterte mit "Das Video konnte nicht verarbeitet
// werden."
serverExternalPackages: ["pdfkit", "ffmpeg-static"],
// Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack) // Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack)
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis // kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
+91 -27
View File
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { readFile } from "node:fs/promises"; import { createReadStream } from "node:fs";
import { contentTypeForExtension, resolveUploadPath } from "@/lib/uploads"; import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { contentTypeForExtension, parseRangeHeader, resolveUploadPath, uploadETag } from "@/lib/uploads";
// node:fs braucht die Node-Runtime (nicht Edge). // node:fs braucht die Node-Runtime (nicht Edge).
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -10,28 +12,60 @@ export const runtime = "nodejs";
// unter einem einmal ausgelieferten Pfad aendert sich also nie mehr. // unter einem einmal ausgelieferten Pfad aendert sich also nie mehr.
const CACHE_CONTROL = "public, max-age=31536000, immutable"; const CACHE_CONTROL = "public, max-age=31536000, immutable";
function notFound(): NextResponse {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
/** /**
* Liefert hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos) aus, die unter * Header, die jede Antwort dieser Route traegt — auch 304 und 416, damit
* UPLOAD_DIR liegen. Next.js' statischer public/-Ordner wird zur Build-Zeit * Caches und Browser in allen Faellen dieselbe Aussage ueber Cachebarkeit,
* fest ins Docker-Image gebacken und kann keine zur Laufzeit hinzugefuegten * Typ und Sniffing-Verbot sehen.
* Dateien ausliefern — dieser dynamische Route-Handler streamt sie */
* stattdessen direkt von der Platte (in Produktion ein SMB-Mount, siehe function baseHeaders(contentType: string, etag: string): Record<string, string> {
* UPLOAD_DIR in lib/uploads.ts). return {
"Content-Type": contentType,
"Content-Disposition": "inline",
// Verhindert, dass der Browser den deklarierten Content-Type anhand
// des Inhalts "korrigiert" (MIME-Sniffing) — zusaetzliche Absicherung
// neben der festen Content-Type-Zuordnung unten.
"X-Content-Type-Options": "nosniff",
"Cache-Control": CACHE_CONTROL,
ETag: etag,
// Sagt dem Browser ueberhaupt erst, dass er Teilbereiche anfordern darf
// — ohne diesen Header versucht z.B. ein <video> gar keinen Range-
// Request und kann folglich auch nicht springen.
"Accept-Ranges": "bytes",
};
}
/**
* Liefert hochgeladene Bilder und Videos (Inhalte-/Produkt-/Erntenfotos,
* Hero-Video) aus, die unter UPLOAD_DIR liegen. Next.js' statischer
* public/-Ordner wird zur Build-Zeit fest ins Docker-Image gebacken und kann
* keine zur Laufzeit hinzugefuegten Dateien ausliefern — dieser dynamische
* Route-Handler streamt sie stattdessen direkt von der Platte (in Produktion
* ein gemountetes Volume, siehe UPLOAD_DIR in lib/uploads.ts).
* *
* Oeffentlich erreichbar ohne Admin-Pruefung (die Bilder sind fuer die * Die Datei wird als Stream durchgereicht statt vollstaendig in den Speicher
* gelesen: ein Hero-Video darf bis zu MAX_UPLOAD_VIDEO_BYTES gross sein, und
* dessen kompletter Inhalt pro gleichzeitigem Zugriff im RAM waere weder
* noetig noch tragbar. Unterstuetzt werden ausserdem bedingte Anfragen
* (If-None-Match -> 304) und Bereichsanfragen (Range -> 206), letzteres ist
* Voraussetzung dafuer, dass ein <video> ueberhaupt abspielbar und
* springbar ist (siehe parseRangeHeader in lib/uploads.ts).
*
* Oeffentlich erreichbar ohne Admin-Pruefung (die Dateien sind fuer die
* oeffentlichen Shop-/Homepage-Seiten bestimmt) — geschuetzt wird stattdessen * oeffentlichen Shop-/Homepage-Seiten bestimmt) — geschuetzt wird stattdessen
* NUR das Schreiben (app/api/admin/upload/route.ts). * NUR das Schreiben (app/api/admin/upload/route.ts).
*/ */
export async function GET( export async function GET(
_request: Request, request: Request,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
) { ) {
const { path: segments } = await params; const { path: segments } = await params;
const resolved = resolveUploadPath(segments); const resolved = resolveUploadPath(segments);
if (!resolved) { if (!resolved) return notFound();
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
const extension = resolved.slice(resolved.lastIndexOf(".") + 1).toLowerCase(); const extension = resolved.slice(resolved.lastIndexOf(".") + 1).toLowerCase();
// Content-Type kommt ausschliesslich aus dieser festen Zuordnung nach // Content-Type kommt ausschliesslich aus dieser festen Zuordnung nach
@@ -42,26 +76,56 @@ export async function GET(
// ausgeliefert; alles andere unter UPLOAD_DIR bleibt ueber diese Route // ausgeliefert; alles andere unter UPLOAD_DIR bleibt ueber diese Route
// unerreichbar. // unerreichbar.
const contentType = contentTypeForExtension(extension); const contentType = contentTypeForExtension(extension);
if (!contentType) { if (!contentType) return notFound();
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
let data: Buffer; let size: number;
let mtimeMs: number;
try { try {
data = await readFile(resolved); const stats = await stat(resolved);
// Ein Verzeichnis unter einem erlaubten Namen (theoretisch anlegbar auf
// dem gemounteten Volume) darf nicht als Datei ausgeliefert werden.
if (!stats.isFile()) return notFound();
size = stats.size;
mtimeMs = stats.mtimeMs;
} catch { } catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 }); return notFound();
} }
return new NextResponse(new Uint8Array(data), { const etag = uploadETag(size, mtimeMs);
const headers = baseHeaders(contentType, etag);
// Bedingte Anfrage: der Browser hat die Datei bereits und fragt nur, ob
// sie noch aktuell ist. 304 spart die gesamte Nutzlast.
if (request.headers.get("if-none-match") === etag) {
return new NextResponse(null, { status: 304, headers });
}
const range = parseRangeHeader(request.headers.get("range"), size);
if (range === "unsatisfiable") {
return new NextResponse(null, {
status: 416,
headers: { ...headers, "Content-Range": `bytes */${size}` },
});
}
if (range) {
const length = range.end - range.start + 1;
const stream = Readable.toWeb(
createReadStream(resolved, { start: range.start, end: range.end })
) as ReadableStream<Uint8Array>;
return new NextResponse(stream, {
status: 206,
headers: { headers: {
"Content-Type": contentType, ...headers,
"Content-Disposition": "inline", "Content-Range": `bytes ${range.start}-${range.end}/${size}`,
// Verhindert, dass der Browser den deklarierten Content-Type anhand "Content-Length": String(length),
// des Inhalts "korrigiert" (MIME-Sniffing) — zusaetzliche Absicherung
// neben der festen Content-Type-Zuordnung oben.
"X-Content-Type-Options": "nosniff",
"Cache-Control": CACHE_CONTROL,
}, },
}); });
}
const stream = Readable.toWeb(createReadStream(resolved)) as ReadableStream<Uint8Array>;
return new NextResponse(stream, {
headers: { ...headers, "Content-Length": String(size) },
});
} }
+10 -2
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import Image from "next/image";
import { useRef, useState, type ChangeEvent } from "react"; import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider"; import { useI18n } from "@/i18n/i18n-provider";
@@ -56,8 +57,15 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
<input type="hidden" name={name} value={value} /> <input type="hidden" name={name} value={value} />
<div className="mt-2 flex items-center gap-4"> <div className="mt-2 flex items-center gap-4">
{value ? ( {value ? (
// eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht. // Ueber next/image statt als rohes <img>: gespeichert wird ein Bild
<img src={value} alt="" className="h-20 w-20 object-cover border border-ink/10" /> // mit bis zu MAX_IMAGE_DIMENSION Kantenlaenge (siehe lib/uploads.ts),
// also durchaus ein paar hundert KB. Als rohes <img> laedt die
// Admin-Seite dieses Vollbild fuer eine 80x80-Kachel — bei einem
// Formular mit mehreren Bildern summiert sich das spuerbar. Der
// Optimierer liefert stattdessen genau die gebrauchte Groesse.
<div className="relative h-20 w-20 flex-shrink-0 overflow-hidden border border-ink/10">
<Image src={value} alt="" fill sizes="80px" className="object-cover" />
</div>
) : ( ) : (
<div className="h-20 w-20 border border-dashed border-ink/20 flex items-center justify-center text-center text-[10px] text-ink/40 px-1"> <div className="h-20 w-20 border border-dashed border-ink/20 flex items-center justify-center text-center text-[10px] text-ink/40 px-1">
{t.adminImageField.none} {t.adminImageField.none}
+8 -2
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import Image from "next/image";
import { useRef, useState, type ChangeEvent } from "react"; import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider"; import { useI18n } from "@/i18n/i18n-provider";
@@ -67,8 +68,13 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
<div className="mt-2 grid grid-cols-4 gap-3"> <div className="mt-2 grid grid-cols-4 gap-3">
{images.map((src, i) => ( {images.map((src, i) => (
<div key={`${i}-${src}`} className="relative"> <div key={`${i}-${src}`} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht. */} {/* Ueber next/image statt als rohes <img> — siehe Begruendung in
<img src={src} alt="" className="aspect-square w-full object-cover border border-ink/10" /> admin-image-field.tsx. Hier wiegt es schwerer, weil ein
Produkt mehrere Bilder tragen kann und sonst JEDES davon in
voller Groesse fuer eine Miniatur geladen wuerde. */}
<div className="relative aspect-square w-full overflow-hidden border border-ink/10">
<Image src={src} alt="" fill sizes="160px" className="object-cover" />
</div>
<button <button
type="button" type="button"
onClick={() => removeAt(i)} onClick={() => removeAt(i)}
+44 -1
View File
@@ -12,9 +12,14 @@ afterEach(() => {
else process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl; else process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl;
}); });
function makeRequest(url: string, origin?: string): Request { function makeRequest(
url: string,
origin?: string,
extraHeaders: Record<string, string> = {}
): Request {
const headers = new Headers(); const headers = new Headers();
if (origin !== undefined) headers.set("origin", origin); if (origin !== undefined) headers.set("origin", origin);
for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value);
return new Request(url, { method: "POST", headers }); return new Request(url, { method: "POST", headers });
} }
@@ -65,4 +70,42 @@ describe("isTrustedOrigin", () => {
const request = makeRequest("http://app:3000/api/admin/upload", "https://attacker.example"); const request = makeRequest("http://app:3000/api/admin/upload", "https://attacker.example");
expect(isTrustedOrigin(request)).toBe(false); expect(isTrustedOrigin(request)).toBe(false);
}); });
test("accepts an origin matching the Host header even when request.url's host differs", () => {
// Der eigentliche Live-Bug: Next.js rekonstruiert request.url aus der
// internen Bind-Adresse des Servers, NICHT aus dem Host-Header, den der
// Browser tatsaechlich adressiert hat. Ist NEXT_PUBLIC_SITE_URL nicht
// (oder nicht exakt passend) gesetzt, schlug damit jeder Admin-Upload
// mit "Ungueltige Anfrage." fehl, obwohl Origin und adressierter Host
// identisch waren.
const request = makeRequest("http://localhost:3000/api/admin/upload", "https://wabenhain.com", {
host: "wabenhain.com",
});
expect(isTrustedOrigin(request)).toBe(true);
});
test("accepts an origin matching X-Forwarded-Host set by the reverse proxy", () => {
const request = makeRequest("http://localhost:3000/api/admin/upload", "https://wabenhain.com", {
host: "app:3000",
"x-forwarded-host": "wabenhain.com",
});
expect(isTrustedOrigin(request)).toBe(true);
});
test("uses only the first entry of a comma-separated X-Forwarded-Host chain", () => {
const request = makeRequest("http://localhost:3000/api/admin/upload", "https://wabenhain.com", {
"x-forwarded-host": "wabenhain.com, internal.example",
});
expect(isTrustedOrigin(request)).toBe(true);
});
test("rejects a forged cross-origin request even though the Host header is trusted", () => {
// Gegenprobe zur Host-Header-Pruefung: ein Cross-Site-POST traegt zwar
// den echten Ziel-Host (der Browser setzt ihn, nicht die Angreiferseite),
// aber den fremden Origin — muss weiterhin abgelehnt werden.
const request = makeRequest("http://localhost:3000/api/admin/upload", "https://attacker.example", {
host: "wabenhain.com",
});
expect(isTrustedOrigin(request)).toBe(false);
});
}); });
+75 -26
View File
@@ -1,32 +1,81 @@
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
/**
* Hosts, die fuer diesen Request als "eigener Origin" gelten duerfen.
*
* Reihenfolge nach Verlaesslichkeit:
* 1. X-Forwarded-Host — der oeffentliche Host, den der eigene Reverse-Proxy
* (Caddy/nginx, siehe Caddyfile bzw. k8s/06-ingress.yaml) weiterreicht.
* Caddy verwirft einen vom Client mitgeschickten X-Forwarded-Host per
* Default, der Wert stammt hier also immer vom Proxy selbst.
* 2. Host — der Host, den der Browser tatsaechlich adressiert hat. Das ist
* die massgebliche Groesse fuer eine Same-Origin-Pruefung und genau der
* Vergleich, den Next.js fuer Server Actions intern selbst anstellt.
* 3. request.url — nur noch als Rueckfallebene. Next.js rekonstruiert diese
* URL aus der internen Bind-Adresse des Servers, NICHT aus dem
* Host-Header; sie traegt daher z.B. "localhost:3000", waehrend der
* Browser "wabenhain.com" adressiert hat.
* 4. NEXT_PUBLIC_SITE_URL (getBaseUrl) — die konfigurierte oeffentliche
* Adresse, falls gesetzt.
*
* Warum der Host-Header hier sicher ist: eine fremde Seite kann bei einem
* Cross-Site-Request weder Host noch X-Forwarded-Host bestimmen. Den
* Host-Header setzt der Browser aus der Ziel-URL des Requests (also dem
* eigenen Host), und eigene Header wie X-Forwarded-Host liessen sich nur
* per fetch() setzen, was einen CORS-Preflight ausloest, den dieser
* Endpunkt nicht beantwortet. Ein gefaelschter Cross-Site-POST traegt also
* weiterhin zwangslaeufig einen fremden Origin und faellt durch.
*/
function trustedHosts(request: Request): string[] {
const hosts: string[] = [];
// Bei mehreren Proxies steht der urspruenglich vom Client adressierte Host
// als ERSTER Eintrag einer kommaseparierten Kette.
const forwardedHost = request.headers.get("x-forwarded-host");
if (forwardedHost) hosts.push(forwardedHost.split(",")[0].trim());
const host = request.headers.get("host");
if (host) hosts.push(host.trim());
try {
hosts.push(new URL(request.url).host);
} catch {
// request.url ist in der Praxis immer parsebar; ein Fehler hier darf die
// uebrigen Kandidaten nicht mitreissen.
}
try {
hosts.push(new URL(getBaseUrl()).host);
} catch {
// Falsch gesetztes NEXT_PUBLIC_SITE_URL darf die Pruefung nicht sprengen.
}
return hosts.filter(Boolean);
}
/** /**
* Same-Origin-Pruefung fuer zustandsaendernde app/api/.../route.ts-Handler. * Same-Origin-Pruefung fuer zustandsaendernde app/api/.../route.ts-Handler.
* *
* Next.js' eingebauter CSRF-Schutz (Origin-Header gegen Host geprueft) gilt * Next.js' eingebauter CSRF-Schutz (Origin-Header gegen Host geprueft) gilt
* nur fuer Server Actions ("use server") — eigene Route-Handler wie * nur fuer Server Actions ("use server") — eigene Route-Handler wie
* /api/checkout sind normale POST-Endpunkte ohne diesen automatischen Schutz * /api/checkout oder /api/admin/upload sind normale POST-Endpunkte ohne
* und muessen ihn sich selbst holen. Ohne diese Pruefung liesse sich ein * diesen automatischen Schutz und muessen ihn sich selbst holen. Ohne diese
* state-aendernder POST z.B. per <form enctype="text/plain"> als "einfacher" * Pruefung liesse sich ein state-aendernder POST z.B. per
* (nicht preflight-pflichtiger) Cross-Origin-Request faelschen (bekannte * <form enctype="text/plain"> als "einfacher" (nicht preflight-pflichtiger)
* JSON-CSRF-Umgehung) — bei /api/checkout koennte das, sofern der Besuch von * Cross-Origin-Request faelschen (bekannte JSON-CSRF-Umgehung) — bei
* einer eingeloggten Kundschaft ausgeht, Bestellungen unter deren Konto * /api/checkout koennte das, sofern der Besuch von einer eingeloggten
* anlegen und Lagerbestand reservieren, ohne dass die Kundschaft das wollte. * Kundschaft ausgeht, Bestellungen unter deren Konto anlegen und
* Lagerbestand reservieren, ohne dass die Kundschaft das wollte.
* *
* Akzeptiert den Origin, wenn er zu request.url ODER zu getBaseUrl() * Der Origin gilt als vertrauenswuerdig, wenn er zu IRGENDEINEM der in
* (NEXT_PUBLIC_SITE_URL) passt — nicht nur zu request.url allein. Hinter * trustedHosts() aufgefuehrten Hosts passt. Fruehere Fassungen verglichen
* einem selbst betriebenen Reverse-Proxy (Caddy/nginx, siehe * ausschliesslich gegen new URL(request.url).host (+ NEXT_PUBLIC_SITE_URL)
* docker-compose.yml bzw. k8s/) ist der vom Next.js-Prozess intern * — weil Next.js request.url aber aus der internen Bind-Adresse
* rekonstruierte Host aus request.url nicht in jeder Proxy-Konstellation * rekonstruiert, schlug damit jeder Admin-Upload mit "Ungueltige Anfrage."
* zuverlaessig derselbe wie der echte, oeffentliche Host, den der Browser im * (403) fehl, sobald der Browser den Server unter einem anderen Namen als
* Origin-Header sendet (vgl. denselben Grundsatz bereits in lib/url.ts) — * dieser internen Adresse erreichte (oeffentliche Domain hinter dem Proxy,
* das fuehrte live faelschlich zu "Ungueltige Anfrage." bei jedem * LAN-IP, 127.0.0.1 statt localhost) und NEXT_PUBLIC_SITE_URL nicht exakt
* Admin-Upload, obwohl der Request tatsaechlich vom eigenen Frontend kam. * dazu passte.
* Die zusaetzliche ODER-Bedingung statt eines reinen Ersatzes ist bewusst
* risikoarm: sie akzeptiert nur MEHR legitime Faelle als vorher, verengt
* nichts — verhindert also, dass ein falsch/veraltet gesetztes
* NEXT_PUBLIC_SITE_URL ploetzlich bislang funktionierende Endpunkte wie
* /api/checkout fuer echte Kundschaft blockiert.
*/ */
export function isTrustedOrigin(request: Request): boolean { export function isTrustedOrigin(request: Request): boolean {
const origin = request.headers.get("origin"); const origin = request.headers.get("origin");
@@ -37,13 +86,13 @@ export function isTrustedOrigin(request: Request): boolean {
// Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren. // Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren.
if (!origin) return true; if (!origin) return true;
let originHost: string;
try { try {
const originHost = new URL(origin).host; originHost = new URL(origin).host;
const requestHost = new URL(request.url).host;
if (requestHost === originHost) return true;
const trustedHost = new URL(getBaseUrl()).host;
return trustedHost === originHost;
} catch { } catch {
return false; return false;
} }
if (!originHost) return false;
return trustedHosts(request).includes(originHost);
} }
+81
View File
@@ -0,0 +1,81 @@
import { describe, test, expect } from "vitest";
import { parseRangeHeader, uploadETag } from "./uploads";
describe("parseRangeHeader", () => {
test("returns null when no Range header was sent", () => {
expect(parseRangeHeader(null, 5000)).toBeNull();
});
test("parses a normal closed range", () => {
expect(parseRangeHeader("bytes=0-1023", 5000)).toEqual({ start: 0, end: 1023 });
});
test("parses an open-ended range up to the last byte", () => {
expect(parseRangeHeader("bytes=1024-", 5000)).toEqual({ start: 1024, end: 4999 });
});
test("parses a suffix range (last N bytes)", () => {
expect(parseRangeHeader("bytes=-500", 5000)).toEqual({ start: 4500, end: 4999 });
});
test("clamps a suffix range longer than the file to the whole file", () => {
expect(parseRangeHeader("bytes=-99999", 5000)).toEqual({ start: 0, end: 4999 });
});
test("clamps an end beyond the last byte", () => {
expect(parseRangeHeader("bytes=0-99999", 5000)).toEqual({ start: 0, end: 4999 });
});
test("accepts the single-byte probe iOS Safari sends before playing a video", () => {
expect(parseRangeHeader("bytes=0-1", 5000)).toEqual({ start: 0, end: 1 });
});
test("reports a start beyond the end of the file as unsatisfiable", () => {
expect(parseRangeHeader("bytes=5000-", 5000)).toBe("unsatisfiable");
});
test("reports a zero-length suffix range as unsatisfiable", () => {
expect(parseRangeHeader("bytes=-0", 5000)).toBe("unsatisfiable");
});
test("reports an inverted range as unsatisfiable", () => {
expect(parseRangeHeader("bytes=300-100", 5000)).toBe("unsatisfiable");
});
test("reports any range against an empty file as unsatisfiable", () => {
expect(parseRangeHeader("bytes=0-", 0)).toBe("unsatisfiable");
});
test("ignores a non-bytes unit and serves the whole file", () => {
expect(parseRangeHeader("items=0-100", 5000)).toBeNull();
});
test("ignores a multi-range request and serves the whole file", () => {
// Mehrteilige Bereiche waeren eine multipart/byteranges-Antwort — fuer
// Bilder/Videos praktisch nie angefragt. Die ganze Datei auszuliefern
// ist laut RFC 9110 eine zulaessige Antwort auf jeden Range-Request.
expect(parseRangeHeader("bytes=0-10, 20-30", 5000)).toBeNull();
});
test("ignores a syntactically broken range instead of failing the request", () => {
expect(parseRangeHeader("bytes=abc-def", 5000)).toBeNull();
expect(parseRangeHeader("bytes=-", 5000)).toBeNull();
expect(parseRangeHeader("bytes=", 5000)).toBeNull();
});
});
describe("uploadETag", () => {
test("is a quoted, stable token for the same size and mtime", () => {
const tag = uploadETag(1234, 1700000000000);
expect(tag).toMatch(/^"[^"]+"$/);
expect(uploadETag(1234, 1700000000000)).toBe(tag);
});
test("changes when the file size changes", () => {
expect(uploadETag(1234, 1700000000000)).not.toBe(uploadETag(1235, 1700000000000));
});
test("changes when the modification time changes", () => {
expect(uploadETag(1234, 1700000000000)).not.toBe(uploadETag(1234, 1700000000001));
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, test, expect } from "vitest";
import sharp from "sharp";
import { MAX_IMAGE_DIMENSION, MAX_UPLOAD_BYTES, reencodeImage } from "./uploads";
async function makeImage(width: number, height: number): Promise<Buffer> {
return sharp({ create: { width, height, channels: 3, background: { r: 210, g: 160, b: 40 } } })
.jpeg({ quality: 90 })
.toBuffer();
}
describe("reencodeImage", () => {
test("scales an oversized landscape photo down to the long-edge limit and keeps its aspect ratio", async () => {
// Ein 24-MP-Handyfoto wurde bisher unveraendert gross gespeichert: das
// kostete Platz auf dem Upload-Volume UND zwang next/image, fuer jede
// ausgelieferte Groesse das komplette Bild neu zu dekodieren.
const source = await makeImage(6000, 4000);
const meta = await sharp(await reencodeImage(source, "jpg")).metadata();
expect(meta.width).toBe(MAX_IMAGE_DIMENSION);
expect(meta.height).toBe(Math.round((MAX_IMAGE_DIMENSION * 4000) / 6000));
});
test("scales an oversized portrait photo by its height instead", async () => {
const source = await makeImage(3000, 4500);
const meta = await sharp(await reencodeImage(source, "webp")).metadata();
expect(meta.height).toBe(MAX_IMAGE_DIMENSION);
expect(meta.width).toBe(Math.round((MAX_IMAGE_DIMENSION * 3000) / 4500));
});
test("leaves an image that is already small enough at its original size", async () => {
const source = await makeImage(1200, 800);
const meta = await sharp(await reencodeImage(source, "jpg")).metadata();
expect(meta.width).toBe(1200);
expect(meta.height).toBe(800);
});
test("never enlarges a small image", async () => {
const source = await makeImage(320, 240);
const meta = await sharp(await reencodeImage(source, "png")).metadata();
expect(meta.width).toBe(320);
expect(meta.height).toBe(240);
});
test("produces a file that is smaller than the oversized original", async () => {
const source = await makeImage(6000, 4000);
const output = await reencodeImage(source, "jpg");
expect(output.length).toBeLessThan(source.length);
});
test("rejects a decompression bomb instead of decoding it", async () => {
// Ein kleines File, das sich zu absurd vielen Pixeln entpackt, wuerde
// beim Dekodieren den Arbeitsspeicher fuellen. sharp bekommt deshalb
// eine ausdrueckliche Pixel-Obergrenze.
const bomb = await sharp({ create: { width: 11000, height: 11000, channels: 3, background: { r: 0, g: 0, b: 0 } } })
.png({ compressionLevel: 9 })
.toBuffer();
await expect(reencodeImage(bomb, "jpg")).rejects.toThrow();
}, 120_000);
});
describe("MAX_UPLOAD_BYTES", () => {
test("accommodates a photo straight out of a modern phone camera", () => {
// 8 MB lagen unter der Groesse, die aktuelle Handykameras (48 MP,
// HEIC-Export nach JPEG) routinemaessig erzeugen — der Upload brach mit
// "Die Datei ist zu gross" ab, obwohl das Bild serverseitig ohnehin
// verkleinert und neu enkodiert wird.
expect(MAX_UPLOAD_BYTES).toBeGreaterThanOrEqual(20 * 1024 * 1024);
});
});
+125 -8
View File
@@ -24,13 +24,41 @@ import sharp from "sharp";
export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads"); export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
/** /**
* 8 MB: grosszuegig genug fuer ein einzelnes unkomprimiertes Foto aus einer * 25 MB fuer die HOCHGELADENE Rohdatei. Die vorherigen 8 MB lagen unter der
* aktuellen Smartphone-Kamera nach Export, aber klein genug, um den Server * Groesse, die aktuelle Handykameras (48 MP, HEIC-Export nach JPEG)
* nicht mit riesigen Uploads zu belasten — die Datei wird vollstaendig in * routinemaessig erzeugen — ein voellig normales Foto brach damit mit "Die
* den Speicher gelesen (kein Streaming), um sie mit sharp zu validieren und * Datei ist zu gross" ab. Was tatsaechlich gespeichert wird, ist ohnehin
* neu zu enkodieren. * nicht diese Rohdatei, sondern das von reencodeImage() auf
* MAX_IMAGE_DIMENSION verkleinerte und neu enkodierte Ergebnis (in aller
* Regel ein Bruchteil davon).
*
* Die Datei wird zur Validierung vollstaendig in den Speicher gelesen (kein
* Streaming); gegen ein kleines File, das sich beim Dekodieren zu absurd
* vielen Pixeln aufblaeht, schuetzt zusaetzlich MAX_INPUT_PIXELS.
*/ */
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
/**
* Laengste Kante, mit der ein Bild gespeichert wird. 2560px deckt jede
* Darstellung dieser Seite mit Reserve ab — das groesste Layout ist ein
* vollbreiter Hero, und selbst ein Display mit doppelter Pixeldichte
* verlangt davon keine hoehere Aufloesung. Alles darueber kostete nur Platz
* auf dem Upload-Volume und zwang next/image, fuer JEDE ausgelieferte
* Breite das komplette Originalbild neu zu dekodieren (ein 6000x4000-Foto
* sind 24 Megapixel pro Variante).
*/
export const MAX_IMAGE_DIMENSION = 2560;
/**
* Obergrenze fuer die Pixelzahl, die sharp aus einer hochgeladenen Datei
* ueberhaupt dekodieren darf (100 MP — mehr als jede Kamera liefert, die
* hier realistisch zum Einsatz kommt). Schuetzt vor einer "Dekompressions-
* bombe": einer wenige Kilobyte grossen Datei, die sich zu Milliarden
* Pixeln entpackt und damit den Arbeitsspeicher des Servers fuellt — die
* Byte-Grenze MAX_UPLOAD_BYTES allein faengt das nicht ab, weil sie nur die
* komprimierte Groesse kennt.
*/
export const MAX_INPUT_PIXELS = 100_000_000;
/** /**
* 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video * 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video
@@ -176,10 +204,24 @@ export function resolveUploadPath(segments: string[]): string | null {
* Argumente richtet das Bild einmalig anhand des EXIF-Orientation-Tags aus, * Argumente richtet das Bild einmalig anhand des EXIF-Orientation-Tags aus,
* BEVOR dieser Tag mit den uebrigen Metadaten verworfen wird — sonst * BEVOR dieser Tag mit den uebrigen Metadaten verworfen wird — sonst
* erschiene ein am Handy "hochkant" aufgenommenes Foto nach dem Entfernen * erschiene ein am Handy "hochkant" aufgenommenes Foto nach dem Entfernen
* der Metadaten seitlich verdreht. * der Metadaten seitlich verdreht. Zusaetzlich (3) wird das Bild auf
* MAX_IMAGE_DIMENSION begrenzt, damit nie mehr Aufloesung gespeichert wird,
* als die Seite je ausliefert.
*/ */
export async function reencodeImage(buffer: Buffer, extension: AllowedImageExtension): Promise<Buffer> { export async function reencodeImage(buffer: Buffer, extension: AllowedImageExtension): Promise<Buffer> {
const image = sharp(buffer).rotate(); const image = sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS })
.rotate()
// "inside" skaliert so, dass BEIDE Kanten in das Quadrat passen — bei
// einem Querformat greift damit die Breite, bei einem Hochformat die
// Hoehe, das Seitenverhaeltnis bleibt in beiden Faellen erhalten.
// "withoutEnlargement" stellt sicher, dass ein ohnehin kleineres Bild
// nie hochskaliert (und damit unscharf) wird.
.resize({
width: MAX_IMAGE_DIMENSION,
height: MAX_IMAGE_DIMENSION,
fit: "inside",
withoutEnlargement: true,
});
switch (extension) { switch (extension) {
case "jpg": case "jpg":
return image.jpeg({ quality: 85 }).toBuffer(); return image.jpeg({ quality: 85 }).toBuffer();
@@ -191,3 +233,78 @@ export async function reencodeImage(buffer: Buffer, extension: AllowedImageExten
return image.avif({ quality: 60 }).toBuffer(); return image.avif({ quality: 60 }).toBuffer();
} }
} }
/** Ein halboffenes Byte-Intervall, beide Grenzen inklusive (wie im HTTP-Header). */
export interface ByteRange {
start: number;
end: number;
}
/**
* Wertet einen HTTP-Range-Header gegen die bekannte Dateigroesse aus.
*
* Warum das gebraucht wird: ein <video src="/uploads/..."> (siehe
* components/hero.tsx) laedt sein Material grundsaetzlich per Range-Request.
* iOS-Safari schickt dafuer zuerst eine Ein-Byte-Probe ("bytes=0-1") und
* spielt das Video ueberhaupt nicht ab, wenn der Server darauf mit einer
* vollstaendigen 200-Antwort statt mit "206 Partial Content" antwortet.
* Ohne Range-Unterstuetzung laesst sich ausserdem nirgends im Video
* springen, und jeder Abspielversuch laedt die komplette Datei.
*
* Rueckgabe:
* - `null` -> kein (oder kein auswertbarer) Range-Wunsch, ganze Datei
* ausliefern. Laut RFC 9110 ist das auf JEDEN Range-Request
* eine zulaessige Antwort, deshalb werden auch mehrteilige
* und kaputte Bereiche hierher abgebildet statt als Fehler.
* - `"unsatisfiable"` -> syntaktisch gueltig, aber ausserhalb der Datei:
* muss mit 416 beantwortet werden.
* - `ByteRange` -> der auf die Dateigroesse zugeschnittene Bereich.
*/
export function parseRangeHeader(header: string | null, size: number): ByteRange | "unsatisfiable" | null {
if (!header) return null;
const match = /^bytes=(.+)$/i.exec(header.trim());
if (!match) return null;
const spec = match[1].trim();
// Mehrteilige Bereiche braeuchten eine multipart/byteranges-Antwort — fuer
// Bilder/Videos praktisch nie angefragt, deshalb bewusst nicht unterstuetzt.
if (spec.includes(",")) return null;
const parts = /^(\d*)-(\d*)$/.exec(spec);
if (!parts) return null;
const [, rawStart, rawEnd] = parts;
if (rawStart === "" && rawEnd === "") return null;
// Eine leere Datei kann keinen einzigen Bereich erfuellen.
if (size <= 0) return "unsatisfiable";
const lastByte = size - 1;
// Suffix-Form "bytes=-N": die LETZTEN N Bytes, nicht "ab Byte N".
if (rawStart === "") {
const suffixLength = Number(rawEnd);
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return "unsatisfiable";
return { start: Math.max(0, size - suffixLength), end: lastByte };
}
const start = Number(rawStart);
if (!Number.isSafeInteger(start) || start > lastByte) return "unsatisfiable";
if (rawEnd === "") return { start, end: lastByte };
const requestedEnd = Number(rawEnd);
if (!Number.isSafeInteger(requestedEnd) || requestedEnd < start) return "unsatisfiable";
return { start, end: Math.min(requestedEnd, lastByte) };
}
/**
* Validator fuer bedingte Anfragen (If-None-Match). Dateigroesse und
* Aenderungszeit genuegen als Kennung, weil eine hochgeladene Datei unter
* ihrem zufaelligen Namen nie ueberschrieben wird (siehe
* generateUploadFilename) — ein Wechsel beider Werte ist daher ohnehin nur
* bei einer echten Neuanlage moeglich. Erspart dem Browser bei jedem Reload
* das erneute Herunterladen des Bildinhalts (304 statt 200).
*/
export function uploadETag(size: number, mtimeMs: number): string {
return `"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`;
}