Sichere Bild-Uploads in Next.js

Diagramm eines Uploads im Next.js-Beispiel: Browser, der Route Handler, readForm(), die Prüfungen in lib/gallery.ts und die Uploads-Route, Fehler werden als 400 oder 413 zurückgegeben

Next.js schützt Server Actions auf eine Weise, die Route Handler nicht erhalten, und die Uploads von RichTextEditor gehen an Route Handler. Diese Seite geht durch, was das Next.js-Beispiel selbst ergänzt: eine Origin-Prüfung gegen Cross-Site-POSTs, die Dateiprüfungen, bevor etwas geschrieben wird, und eine Route, die Uploads ausliefert, ohne Ihre Domain zu einem Datei-Hoster zu machen.

Route Handler erhalten keine Origin-Prüfung

Laut dem Leitfaden zur Datensicherheit von Next.js vergleichen Server Actions den Header Origin mit Host (oder X-Forwarded-Host) und brechen den Request ab, wenn sie sich unterscheiden. Route Handler tun nichts dergleichen. Bevor wir eine Prüfung ergänzt hatten, haben wir POST /rte/gallery mit Origin: https://evil.example gesendet, und das Beispiel hat den Ordner angelegt: 200. Wenn Ihr Login Cookies verwendet, könnte jede andere Website den Browser eines angemeldeten Benutzers dazu bringen. Das Beispiel lehnt das jetzt ab:

TypeScript · lib/gallery.ts
// Next.js checks the Origin of Server Actions, not of route handlers. A browser sends Origin
// with these POSTs, so refuse one from another site. Requests without Origin (curl) pass.
export function checkOrigin(request: Request): void {
  const origin = request.headers.get("origin");
  if (!origin) return;
  const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  let originHost: string | null = null;
  try {
    originHost = new URL(origin).host;
  } catch {}
  if (originHost !== host) throw new GalleryError(403, "Cross-site request refused.");
}

Beide POST-Handler rufen zuerst checkOrigin(request) auf, bevor der Body gelesen wird. Wir haben geprüft, dass Chromium bei den eigenen Uploads des Editors Origin: http://localhost:5102 sendet, diese kommen also durch. Ein Request ganz ohne Origin, etwa von curl oder aus Server-zu-Server-Code, kommt ebenfalls durch. Das ist kein Cross-Site-Request eines Browsers, und Ihre Login-Prüfung gilt dafür weiterhin. Origin: null, das Seiten in einer Sandbox senden, wird abgelehnt. Hinter einem Reverse Proxy, der den Hostnamen ändert, stellen Sie sicher, dass er X-Forwarded-Host setzt, sonst lehnt die Prüfung auch Ihre eigenen Seiten ab.

Was vor dem Schreiben geprüft wird

Das Diagramm oben zeigt die Reihenfolge bei einem Upload. Nach der Origin-Prüfung begrenzt readForm() die Größe des Bodys (siehe Größenlimits für Bild-Uploads in Next.js), dann prüft lib/gallery.ts die Datei:

TypeScript · lib/gallery.ts
// Allowed extensions, each with a check of the file's first bytes (its signature).
const SIGNATURES: Record<string, (bytes: Buffer) => boolean> = {
  ".jpg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".jpeg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".png": (b) => hex(b, 0, 8) === "89504e470d0a1a0a",
  ".gif": (b) => ascii(b, 0, 6) === "GIF87a" || ascii(b, 0, 6) === "GIF89a",
  ".webp": (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP",
};
const hex = (bytes: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("hex");
const ascii = (bytes: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("latin1");

// True for "chair.jpg", false for ".chair.jpg", "chair.svg" or "chair".
export function isImageName(name: string): boolean {
  return !name.startsWith(".") && Object.hasOwn(SIGNATURES, extensionOf(name));
}

// A folder or file name: 1-64 letters, digits, space, - _ . and not starting with a dot.
// Also refused: a trailing dot or space (Windows drops them, so "a." would become "a")
// and Windows device names such as CON or NUL.
export function isValidName(name: unknown): name is string {
  return (
    typeof name === "string" &&
    /^[A-Za-z0-9 _.-]{1,64}$/.test(name) &&
    !name.startsWith(".") &&
    !/[. ]$/.test(name) &&
    !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(name)
  );
}
  • Die Dateiendung muss auf der Allowlist stehen: .jpg, .jpeg, .png, .gif oder .webp. SVG fehlt, weil es Skripte enthalten kann.
  • Die ersten Bytes müssen zur Dateiendung passen. File.type aus formData() wird nie verwendet: Es ist das, was der Browser behauptet hat.
  • Der Ordner muss ein gültiger virtueller Pfad sein, dessen realer Pfad innerhalb von uploads/ liegt, wie in Galerieordner in Node.js.
  • Der gespeicherte Name wird generiert, und writeFile läuft mit "wx", überschreibt also nie.

Eine Signaturprüfung beweist, wie eine Datei beginnt, nicht, dass sie ein Bild ist. Eine Datei, die mit JPEG-Bytes beginnt und mit HTML weitergeht, besteht sie. Deshalb ist es wichtig, wie Dateien ausgeliefert werden.

Uploads ohne public/ ausliefern

Uploads können nicht in public/ liegen, weil next start nur die Dateien ausliefert, die zum Zeitpunkt des Builds dort waren. Stattdessen liefert sie app/uploads/[...path]/route.ts aus, und damit haben Sie einen Ort für Regeln, die public/ nicht hätte:

TypeScript · lib/gallery.ts
// /uploads/<parts> -> the image file on disk, or a 404 GalleryError.
export async function resolveUpload(parts: string[]): Promise<string> {
  const name = parts.at(-1) ?? "";
  if (!isImageName(name) || /[\\/\0]/.test(name)) {
    throw new GalleryError(404, "Not found.");
  }
  const dir = await resolveFolder(UPLOADS_DIR, parts.slice(0, -1).join("/")).catch(() => {
    throw new GalleryError(404, "Not found.");
  });
  const file = path.join(dir, name);
  const info = await lstat(file).catch(() => null);
  if (!info || !info.isFile()) {
    throw new GalleryError(404, "Not found.");
  }
  return file;
}

Ausgeliefert werden nur Bildnamen und nur aus einem Ordner, der dieselben Pfadprüfungen besteht. Wir haben stray.html, .hidden.png, .secret/a.png, ..%2f..%2fpackage.png, ..%5cpackage.png und a%00.png ausprobiert: Alle erhielten ein 404. Next.js dekodiert die Segmente von [...path], bevor die Route sie sieht, daher kommt %2f als echtes / innerhalb eines Segments an, und die Namensprüfung lehnt es ab. Auch eine in uploads/ angelegte Windows-Junction wurde abgelehnt, weil die Prüfung reale Pfade vergleicht. Jedes Bild wird mit X-Content-Type-Options: nosniff gesendet, sodass ein Browser den Typ image/* beibehält und keine Datei als HTML ausführt.

Alle drei Handler exportieren runtime = "nodejs". Sie brauchen node:fs, und die Next.js-Dokumentation zur Edge-Runtime ist da eindeutig: Dort können Sie nicht auf das Dateisystem lesend oder schreibend zugreifen.

Was das Beispiel Ihnen überlässt

  • Wer hochladen darf. Das Beispiel hat kein Login. In lib/root-for.ts käme ein Stammordner pro Benutzer hin, und die gemeinsame Galerie hält uploads/users/ dafür bereits unerreichbar.
  • Wer ein Bild sehen darf. /uploads/... ist öffentlich. Ergänzen Sie Ihre Session-Prüfung in der Uploads-Route, wenn Bilder privat sind.
  • Kontingente und das Dekodieren von Bildern mit einer Bibliothek, wenn Sie wissen müssen, dass es vollständige Bilder sind.

Testen

Starten Sie das Beispielprojekt zu dieser Anleitung mit npm run build und npm start, dann:

Shell
curl -H "Origin: https://evil.example" -F action=create-folder -F folder= -F name=x http://localhost:5102/rte/gallery
{"error":"Cross-site request refused."}

printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5102/rte/upload
{"error":"The file content is not a valid JPG image."}

curl -F "file=@photo.jpg;filename=x.svg" http://localhost:5102/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

Die Statuscodes sind 403, 400 und 400. Im Galerie-Dialog zeigt eine abgelehnte Datei „Upload failed for <name>.“: Der Dialog zeigt die Meldung des Servers nicht an.

Dieselbe Anleitung für andere Plattformen

JavaScript · PHP · ASP.NET Core · Razor Pages · MVC 5 · Web Forms

Weiterlesen