Compare commits
4
Commits
a0f61d328c
...
be5fb32a4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5fb32a4a | ||
|
|
cd8e307faf | ||
|
|
0920d33721 | ||
|
|
abdebfc477 |
No files matched your search
+1
-1
@@ -19,7 +19,7 @@ metadata:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
# 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_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht
|
||||
# nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch
|
||||
|
||||
+15
-6
@@ -9,12 +9,21 @@ const nextConfig: NextConfig = {
|
||||
// wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt.
|
||||
output: "standalone",
|
||||
|
||||
// pdfkit laedt seine Schriftart-Metadaten (.afm-Dateien) zur Laufzeit ueber
|
||||
// einen eigenen __dirname-relativen Pfad. Wird das Paket vom Bundler
|
||||
// (Turbopack/Webpack) mitgebuendelt, geht dieser Pfad verloren (ENOENT).
|
||||
// "serverExternalPackages" laesst Next.js es stattdessen ganz normal per
|
||||
// require() aus node_modules laden.
|
||||
serverExternalPackages: ["pdfkit"],
|
||||
// Pakete, die zur Laufzeit eigene Dateien ueber einen __dirname-relativen
|
||||
// Pfad nachladen. Werden sie vom Bundler (Turbopack/Webpack) mitgebuendelt,
|
||||
// zeigt ihr __dirname auf einen virtuellen Bundle-Pfad ("/ROOT/...") statt
|
||||
// auf das echte Verzeichnis in node_modules — der Zugriff schlaegt dann mit
|
||||
// ENOENT fehl. "serverExternalPackages" laesst Next.js sie stattdessen ganz
|
||||
// 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)
|
||||
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { contentTypeForExtension, resolveUploadPath } from "@/lib/uploads";
|
||||
import { createReadStream } from "node:fs";
|
||||
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).
|
||||
export const runtime = "nodejs";
|
||||
@@ -10,28 +12,60 @@ export const runtime = "nodejs";
|
||||
// unter einem einmal ausgelieferten Pfad aendert sich also nie mehr.
|
||||
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
|
||||
* 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 SMB-Mount, siehe
|
||||
* UPLOAD_DIR in lib/uploads.ts).
|
||||
* Header, die jede Antwort dieser Route traegt — auch 304 und 416, damit
|
||||
* Caches und Browser in allen Faellen dieselbe Aussage ueber Cachebarkeit,
|
||||
* Typ und Sniffing-Verbot sehen.
|
||||
*/
|
||||
function baseHeaders(contentType: string, etag: string): Record<string, string> {
|
||||
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
|
||||
* NUR das Schreiben (app/api/admin/upload/route.ts).
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path: segments } = await params;
|
||||
|
||||
const resolved = resolveUploadPath(segments);
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (!resolved) return notFound();
|
||||
|
||||
const extension = resolved.slice(resolved.lastIndexOf(".") + 1).toLowerCase();
|
||||
// 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
|
||||
// unerreichbar.
|
||||
const contentType = contentTypeForExtension(extension);
|
||||
if (!contentType) {
|
||||
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (!contentType) return notFound();
|
||||
|
||||
let data: Buffer;
|
||||
let size: number;
|
||||
let mtimeMs: number;
|
||||
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 {
|
||||
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: {
|
||||
"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 oben.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": CACHE_CONTROL,
|
||||
...headers,
|
||||
"Content-Range": `bytes ${range.start}-${range.end}/${size}`,
|
||||
"Content-Length": String(length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const stream = Readable.toWeb(createReadStream(resolved)) as ReadableStream<Uint8Array>;
|
||||
return new NextResponse(stream, {
|
||||
headers: { ...headers, "Content-Length": String(size) },
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
@@ -56,8 +57,15 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
|
||||
<input type="hidden" name={name} value={value} />
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht.
|
||||
<img src={value} alt="" className="h-20 w-20 object-cover border border-ink/10" />
|
||||
// Ueber next/image statt als rohes <img>: gespeichert wird ein Bild
|
||||
// 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">
|
||||
{t.adminImageField.none}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
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">
|
||||
{images.map((src, i) => (
|
||||
<div key={`${i}-${src}`} className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht. */}
|
||||
<img src={src} alt="" className="aspect-square w-full object-cover border border-ink/10" />
|
||||
{/* Ueber next/image statt als rohes <img> — siehe Begruendung in
|
||||
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
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
|
||||
+44
-1
@@ -12,9 +12,14 @@ afterEach(() => {
|
||||
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();
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -65,4 +70,42 @@ describe("isTrustedOrigin", () => {
|
||||
const request = makeRequest("http://app:3000/api/admin/upload", "https://attacker.example");
|
||||
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
@@ -1,32 +1,81 @@
|
||||
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.
|
||||
*
|
||||
* Next.js' eingebauter CSRF-Schutz (Origin-Header gegen Host geprueft) gilt
|
||||
* nur fuer Server Actions ("use server") — eigene Route-Handler wie
|
||||
* /api/checkout sind normale POST-Endpunkte ohne diesen automatischen Schutz
|
||||
* und muessen ihn sich selbst holen. Ohne diese Pruefung liesse sich ein
|
||||
* state-aendernder POST z.B. per <form enctype="text/plain"> als "einfacher"
|
||||
* (nicht preflight-pflichtiger) Cross-Origin-Request faelschen (bekannte
|
||||
* JSON-CSRF-Umgehung) — bei /api/checkout koennte das, sofern der Besuch von
|
||||
* einer eingeloggten Kundschaft ausgeht, Bestellungen unter deren Konto
|
||||
* anlegen und Lagerbestand reservieren, ohne dass die Kundschaft das wollte.
|
||||
* /api/checkout oder /api/admin/upload sind normale POST-Endpunkte ohne
|
||||
* diesen automatischen Schutz und muessen ihn sich selbst holen. Ohne diese
|
||||
* Pruefung liesse sich ein state-aendernder POST z.B. per
|
||||
* <form enctype="text/plain"> als "einfacher" (nicht preflight-pflichtiger)
|
||||
* Cross-Origin-Request faelschen (bekannte JSON-CSRF-Umgehung) — bei
|
||||
* /api/checkout koennte das, sofern der Besuch von einer eingeloggten
|
||||
* 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()
|
||||
* (NEXT_PUBLIC_SITE_URL) passt — nicht nur zu request.url allein. Hinter
|
||||
* einem selbst betriebenen Reverse-Proxy (Caddy/nginx, siehe
|
||||
* docker-compose.yml bzw. k8s/) ist der vom Next.js-Prozess intern
|
||||
* rekonstruierte Host aus request.url nicht in jeder Proxy-Konstellation
|
||||
* zuverlaessig derselbe wie der echte, oeffentliche Host, den der Browser im
|
||||
* Origin-Header sendet (vgl. denselben Grundsatz bereits in lib/url.ts) —
|
||||
* das fuehrte live faelschlich zu "Ungueltige Anfrage." bei jedem
|
||||
* Admin-Upload, obwohl der Request tatsaechlich vom eigenen Frontend kam.
|
||||
* 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.
|
||||
* Der Origin gilt als vertrauenswuerdig, wenn er zu IRGENDEINEM der in
|
||||
* trustedHosts() aufgefuehrten Hosts passt. Fruehere Fassungen verglichen
|
||||
* ausschliesslich gegen new URL(request.url).host (+ NEXT_PUBLIC_SITE_URL)
|
||||
* — weil Next.js request.url aber aus der internen Bind-Adresse
|
||||
* rekonstruiert, schlug damit jeder Admin-Upload mit "Ungueltige Anfrage."
|
||||
* (403) fehl, sobald der Browser den Server unter einem anderen Namen als
|
||||
* dieser internen Adresse erreichte (oeffentliche Domain hinter dem Proxy,
|
||||
* LAN-IP, 127.0.0.1 statt localhost) und NEXT_PUBLIC_SITE_URL nicht exakt
|
||||
* dazu passte.
|
||||
*/
|
||||
export function isTrustedOrigin(request: Request): boolean {
|
||||
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.
|
||||
if (!origin) return true;
|
||||
|
||||
let originHost: string;
|
||||
try {
|
||||
const 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;
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!originHost) return false;
|
||||
|
||||
return trustedHosts(request).includes(originHost);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -24,13 +24,41 @@ import sharp from "sharp";
|
||||
export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
|
||||
|
||||
/**
|
||||
* 8 MB: grosszuegig genug fuer ein einzelnes unkomprimiertes Foto aus einer
|
||||
* aktuellen Smartphone-Kamera nach Export, aber klein genug, um den Server
|
||||
* nicht mit riesigen Uploads zu belasten — die Datei wird vollstaendig in
|
||||
* den Speicher gelesen (kein Streaming), um sie mit sharp zu validieren und
|
||||
* neu zu enkodieren.
|
||||
* 25 MB fuer die HOCHGELADENE Rohdatei. Die vorherigen 8 MB lagen unter der
|
||||
* Groesse, die aktuelle Handykameras (48 MP, HEIC-Export nach JPEG)
|
||||
* routinemaessig erzeugen — ein voellig normales Foto brach damit mit "Die
|
||||
* Datei ist zu gross" ab. Was tatsaechlich gespeichert wird, ist ohnehin
|
||||
* 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
|
||||
@@ -176,10 +204,24 @@ export function resolveUploadPath(segments: string[]): string | null {
|
||||
* Argumente richtet das Bild einmalig anhand des EXIF-Orientation-Tags aus,
|
||||
* BEVOR dieser Tag mit den uebrigen Metadaten verworfen wird — sonst
|
||||
* 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> {
|
||||
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) {
|
||||
case "jpg":
|
||||
return image.jpeg({ quality: 85 }).toBuffer();
|
||||
@@ -191,3 +233,78 @@ export async function reencodeImage(buffer: Buffer, extension: AllowedImageExten
|
||||
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)}"`;
|
||||
}
|
||||
Reference in new issue
Block a user