Shanraq.org Shanraq.org
Загрузка изображений: чужой файл на вашем диске
IT

Go: с нуля до своего блога Урок 40 из 50

Загрузка изображений: чужой файл на вашем диске

Сороковой урок курса по Go. Форма, в которой едет файл, и четыре вещи, которые проверяют до того, как он ляжет на диск: размер, тип по содержимому, собственное имя вместо присланного и место, куда его класть. Заодно приводим /static в порядок: brand, css, images, js, uploads.

Зачем это нужно

Статья без картинки — половина статьи. Добавить загрузку несложно, и именно поэтому её чаще всего делают неправильно.

Файл, который присылает читатель, — это чужие байты с чужим именем, чужим расширением и чужим заявленным типом. Ни одному из этих трёх слов верить нельзя, и каждому соответствует своя дыра: имя уводит файл из вашей папки, расширение прячет скрипт под видом картинки, а размер занимает диск и память.

Сегодня закроем все три — и сразу увидим, что проверка типа состоит не из одного шага, а из двух: первые байты говорят «похоже на картинку», а не «это картинка». Заодно разложим /static так, как раскладывают в настоящих проектах.

Сразу целиком

Новая папка, go mod init sabaq37:

package main

import (
	"bytes"
	"crypto/rand"
	"encoding/base64"
	"encoding/binary"
	"fmt"
	"hash/crc32"
	"image"
	_ "image/gif"
	_ "image/jpeg"
	"image/png"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
)

// maxUpload — сколько байт мы вообще готовы прочитать. Без этого числа любой
// желающий занимает память сервера одним запросом.
const maxUpload = 1 << 20 // 1 МиБ

// allowed — типы, которые мы принимаем. Тип определяем по содержимому,
// а не по расширению и не по тому, что сказал браузер.
var allowed = map[string]string{
	"image/png":  ".png",
	"image/jpeg": ".jpg",
	"image/gif":  ".gif",
}

// maxSide — предел на сторону картинки. Файл в несколько десятков байт может
// заявить двадцать тысяч пикселей, и на распаковку уйдут гигабайты памяти.
// Заголовок читать дёшево, пиксели — нет, поэтому размер проверяют до
// распаковки.
const maxSide = 8000

func main() {
	dir, _ := os.MkdirTemp("", "uploads")
	defer os.RemoveAll(dir)

	srv := httptest.NewServer(http.MaxBytesHandler(routes(dir), maxUpload))
	defer srv.Close()

	fmt.Println("== имя файла из формы")
	for _, name := range []string{"сурет.png", "../../../etc/passwd", "a/b/c.png", ""} {
		fmt.Printf("%-22q -> %q\n", name, filepath.Base(name))
	}

	fmt.Println()
	fmt.Println("== что принимает сервер")
	fmt.Println("настоящий png:      ", send(srv.URL, "photo.png", smallPNG()))
	fmt.Println("html под видом png: ", send(srv.URL, "photo.png", []byte("<html><script>alert(1)</script>")))
	fmt.Println("имя с путём:        ", send(srv.URL, "../../evil.png", smallPNG()))
	fmt.Println("png-бомба:          ", send(srv.URL, "bomb.png", bomb()))
	fmt.Println("слишком большой:    ", send(srv.URL, "big.png", bytes.Repeat(smallPNG(), 200000)))

	fmt.Println()
	fmt.Println("== что осталось в папке")
	entries, _ := os.ReadDir(dir)
	for _, e := range entries {
		fmt.Println(" ", e.Name())
	}
}

func routes(dir string) http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) {
		// 32 КиБ держим в памяти, остальное само уедет во временный файл.
		if err := r.ParseMultipartForm(32 << 10); err != nil {
			http.Error(w, "файл слишком большой", http.StatusRequestEntityTooLarge)
			return
		}
		file, header, err := r.FormFile("image")
		if err != nil {
			http.Error(w, "файла нет", http.StatusBadRequest)
			return
		}
		defer file.Close()

		// Первые 512 байт решают, что это на самом деле. Ни расширение, ни
		// Content-Type от браузера доверия не заслуживают.
		head := make([]byte, 512)
		n, _ := io.ReadFull(file, head)
		kind := strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0]
		ext, ok := allowed[kind]
		if !ok {
			http.Error(w, "это не картинка: "+kind, http.StatusUnsupportedMediaType)
			return
		}
		if _, err := file.Seek(0, io.SeekStart); err != nil {
			http.Error(w, "не вышло", http.StatusInternalServerError)
			return
		}

		// Первые байты — подсказка, а не доказательство: их нетрудно приписать,
		// дописав начало настоящей картинки к любому файлу. Поэтому разбираем
		// заголовок самой картинки — он либо читается, либо нет — и проверяем
		// размеры, которые он объявляет.
		cfg, _, err := image.DecodeConfig(file)
		if err != nil {
			http.Error(w, "это не картинка: "+err.Error(), http.StatusUnsupportedMediaType)
			return
		}
		if cfg.Width > maxSide || cfg.Height > maxSide {
			http.Error(w, fmt.Sprintf("картинка слишком большая: %dx%d", cfg.Width, cfg.Height), http.StatusRequestEntityTooLarge)
			return
		}
		if _, err := file.Seek(0, io.SeekStart); err != nil {
			http.Error(w, "не вышло", http.StatusInternalServerError)
			return
		}

		// Имя придумываем сами. То, что прислали, годится только для показа
		// человеку — и то после экранирования.
		name := random() + ext
		path := filepath.Join(dir, name)
		dst, err := os.Create(path)
		if err != nil {
			http.Error(w, "не вышло", http.StatusInternalServerError)
			return
		}
		defer dst.Close()
		if _, err := io.Copy(dst, file); err != nil {
			// Половина файла на диске хуже, чем ничего.
			os.Remove(path)
			http.Error(w, "не вышло", http.StatusInternalServerError)
			return
		}

		fmt.Fprintf(w, "сохранили как %s (прислали %q)", name, filepath.Base(header.Filename))
	})

	return mux
}

func random() string {
	raw := make([]byte, 12)
	rand.Read(raw)
	return base64.RawURLEncoding.EncodeToString(raw)
}

// smallPNG — настоящая картинка размером один пиксель.
func smallPNG() []byte {
	var buf bytes.Buffer
	png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)))
	return buf.Bytes()
}

// bomb — по первым байтам честный PNG, который заявляет двадцать тысяч
// пикселей в стороне, а весит семьдесят четыре байта.
func bomb() []byte {
	raw := smallPNG()
	binary.BigEndian.PutUint32(raw[16:20], 20000) // ширина в заголовке IHDR
	binary.BigEndian.PutUint32(raw[20:24], 20000) // высота
	binary.BigEndian.PutUint32(raw[29:33], crc32.ChecksumIEEE(raw[12:29]))
	return raw
}

func send(base, name string, body []byte) string {
	var buf bytes.Buffer
	form := multipart.NewWriter(&buf)
	part, err := form.CreateFormFile("image", name)
	if err != nil {
		return "ошибка: " + err.Error()
	}
	part.Write(body)
	form.Close()

	resp, err := http.Post(base+"/upload", form.FormDataContentType(), &buf)
	if err != nil {
		return "ошибка: " + err.Error()
	}
	defer resp.Body.Close()
	out, _ := io.ReadAll(resp.Body)
	return fmt.Sprintf("%d — %s", resp.StatusCode, strings.TrimSpace(string(out)))
}

Вывод:

== имя файла из формы
"сурет.png"            -> "сурет.png"
"../../../etc/passwd"  -> "passwd"
"a/b/c.png"            -> "c.png"
""                     -> "."

== что принимает сервер
настоящий png:       200 — сохранили как pM361aTkUKWtjqjC.png (прислали "photo.png")
html под видом png:  415 — это не картинка: text/html
имя с путём:         200 — сохранили как s035PI2jgJrUCVyT.png (прислали "evil.png")
png-бомба:           413 — картинка слишком большая: 20000x20000
слишком большой:     413 — файл слишком большой

== что осталось в папке
  pM361aTkUKWtjqjC.png
  s035PI2jgJrUCVyT.png

Разбор

multipart/form-data: форма, в которой едет файл

Обычная форма отправляет пары «имя — значение» одной строкой. Файл так не отправишь, поэтому у формы с файлом другой вид:

<form method="post" action="/upload" enctype="multipart/form-data">
  <input type="file" name="image" accept="image/*">
  <button>загрузить</button>
</form>

enctype здесь обязателен. Забудете — браузер отправит только имя файла, без содержимого, и FormFile вернёт ошибку, которую потом долго ищут.

В Go такую форму разбирает ParseMultipartForm, а r.FormFile("image") отдаёт три вещи: сам файл, заголовок с присланным именем и размером, и ошибку.

Предел ставят до чтения, а не после

http.MaxBytesHandler оборачивает сервер и обрывает чтение на заданном числе байт. В выводе видно, чем это кончается для слишком большого файла: 413.

Ставить предел после чтения бессмысленно: чтобы узнать размер, файл придётся сначала прочитать, а значит, уже занять им память или диск. Проверка header.Size — это проверка того, что сказал браузер, и она ничего не гарантирует.

ParseMultipartForm(32 << 10) — второе число, и оно про другое: сколько держать в памяти. Всё, что больше, Go сам положит во временный файл и уберёт за собой.

Образ. Турникет на входе, а не охранник в конце коридора. Турникет не пускает того, кто не проходит по размеру; охранник в конце обнаружит его, когда тот уже внутри.

Имя файла придумываете вы

Первая часть вывода — про имя. filepath.Base отрезает от ../../../etc/passwd всё, кроме passwd, и это уже неплохо. Но полагаться на него одного не стоит: посмотрите на последнюю строку — из пустого имени получается ., и такой файл вы создадите молча.

Поэтому имя не чистят, а придумывают: двенадцать случайных байт плюс расширение, которое мы сами и выбрали. Присланное имя годится только на то, чтобы показать его человеку — и то после экранирования, как в уроке про XSS.

Так решается сразу несколько бед: файл не может уйти из папки, не может затереть чужой, не может оказаться .htaccess или index.html, и в имени не окажется ни пробелов, ни кириллицы, ни точек с запятой.

Тип определяют по содержимому

Вторая строка вывода — главная. Файл с именем photo.png, внутри которого лежит <html><script>, отвергнут с 415, потому что мы спросили не имя, а содержимое:

head := make([]byte, 512)
n, _ := io.ReadFull(file, head)
kind := strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0]

http.DetectContentType смотрит на первые байты и отвечает по тому, что там на самом деле: у PNG это \x89PNG, у JPEG — \xFF\xD8\xFF. Пятьсот двенадцать байт — не наша выдумка, а размер, на который рассчитан алгоритм.

Расширению верить нельзя: его пишет тот, кто присылает. Заголовку Content-Type из формы — тоже: его пишет браузер, а браузер можно не использовать вовсе.

И белый список вместо чёрного: мы перечисляем, что принимаем, а не что запрещаем. Это то же правило, что и с сортировкой в уроке про инъекции, и оно работает по той же причине — фантазия чужого человека богаче вашей.

После проверки не забудьте file.Seek(0, io.SeekStart): пятьсот двенадцать байт уже прочитаны, и без перемотки файл сохранится обрезанным.

Первые байты — подсказка, а не доказательство

DetectContentType смотрит на сигнатуру, а сигнатуру можно приписать: возьмите начало настоящего png, допишите к нему что угодно — и проверка пройдена. Поэтому за ней идёт вторая, честная: стандартная библиотека разбирает заголовок самой картинки.

cfg, _, err := image.DecodeConfig(file)

image.DecodeConfig читает только заголовок — ширину, высоту и модель цвета — и не трогает пиксели. Он либо разберёт файл, либо вернёт ошибку, и здесь уже не отделаешься подделанным началом: у png сходится контрольная сумма, у jpeg — структура сегментов.

Заодно он отвечает на вопрос, который размер файла не закрывает. В выводе есть строка png-бомба: 413. Файл весит семьдесят четыре байта и по всем признакам настоящий, но в заголовке у него написано 20000x20000. Распаковать такую картинку — значит попросить у системы гигабайты памяти на один запрос. Поэтому объявленный размер проверяют до распаковки:

if cfg.Width > maxSide || cfg.Height > maxSide {

Отсюда же понятно, почему в списке нет webp: в стандартной библиотеке нет декодера для него, а принимать формат, который мы не умеем проверить, — значит снова поверить на слово.

И после проверки файл снова перематывают в начало: заголовок прочитан, а на диск должен лечь весь файл.

Дерево /static: brand, css, images, js, uploads

С этого шага блог раскладывает файлы по папкам, и дальше так и будет. Пока их было три, они спокойно лежали рядом — заводить папки ради трёх файлов значит наводить порядок там, где беспорядка ещё нет. Сегодня появляется четвёртый вид файлов, чужой, и с ним правило меняется.

Меняется оно один раз и на будущее. Дальше в блоге появятся шрифты, иконки, скрипт для поиска, картинки-заглушки, обложки статей — и каждый такой вид займёт свою папку, а не ляжет в общую кучу. Раскладывать сорок файлов задним числом дороже, чем завести пять папок сейчас:

static/
    brand/      логотип и иконки: то, что делает сайт узнаваемым
        logo.svg
    css/
        style.css
    js/
        main.js
    images/     картинки самого сайта: иллюстрации, заглушки
        empty.png
    uploads/    то, что прислали читатели
        2026/09/xxxx.png

Правило простое: своё и чужое лежат в разных папках. brand, css, js, images — наши файлы, они едут вместе с программой. uploads — чужие, они появляются во время работы.

Разделение не про красоту. Своё можно вшить в программу через embed и раздавать с длинным кешем; чужое вшить нельзя — embed работает при сборке, а загруженного тогда ещё нет. И правила безопасности у них разные: свои файлы вы написали сами, чужие — нет.

Внутри uploads полезно разложить по годам и месяцам: тысяча файлов в одной папке — уже неудобно, сто тысяч — уже медленно.

Отдавать чужие файлы осторожно

Раздача загруженного — отдельное место, где ошибаются.

http.FileServer по папке uploads — это нормально, но заголовок X-Content-Type-Options: nosniff из прошлого урока здесь обязателен: без него браузер может решить, что ваш «png» на самом деле HTML, и выполнить его.

Дальше — по обстоятельствам. Большие сайты раздают загруженное с отдельного домена, чтобы даже случайно выполненный скрипт оказался «чужим» для основного сайта и не увидел ни печенья, ни страниц. Для блога это перебор, а вот nosniff и белый список типов — необходимый минимум.

Карта урока

Карта урока: размер, тип, имя и место

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему предел на размер ставят обёрткой, а не проверкой header.Size?
  2. Почему имя файла придумывают заново, хотя filepath.Base уже отрезал путь?
  3. Почему тип определяют по первым байтам, а не по расширению?

Задание

Обязательное. Добавьте в блог обложку статьи. Форма получает enctype, обработчик — предел, проверку типа по содержимому, разбор заголовка картинки с пределом на сторону и собственное имя. Файлы ложатся в static/uploads, и /static заодно разъезжается по папкам: brand, css, js, images, uploads.

Всё это сделано в step-25 — сверьтесь после того, как напишете сами.

По желанию.

  • Отправьте через curl файл с именем ../../main.go и убедитесь, что он лёг в uploads под своим новым именем.
  • Разложите uploads по годам и месяцам и напишите, что делать со старым содержимым.
  • Уменьшайте загруженное до разумной ширины перед сохранением. Понадобится пакет image из стандартной библиотеки.

Куда это встанет в блоге

У статьи появляется обложка, а у блога — первая папка, содержимое которой мы не писали. С этого момента /static живёт по правилам: своё отдельно, чужое отдельно.

Долги. Старые файлы не удаляются вместе со статьёй. Картинки не пережимаются: что прислали, то и лежит, вместе с метаданными камеры. И раздаём мы их с того же домена, что и страницы.

Ответы

Показать ответы
  1. Потому что header.Size — это то, что сказал браузер, и узнать настоящий размер можно, только прочитав файл, то есть уже потратив на него память или диск. Обёртка обрывает чтение на нужном байте.
  2. Потому что Base решает только задачу с путём. Остаётся пустое имя, превращающееся в ., совпадение с чужим файлом, опасные имена вроде index.html и всё, что придумает тот, кто присылает. Своё имя снимает весь этот класс вопросов сразу.
  3. Потому что расширение пишет тот, кто присылает файл, и заявленный Content-Type тоже. Первые байты относятся к самому файлу, а не к словам о нём. Но и они только подсказка: настоящий ответ даёт разбор заголовка картинки.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.