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("png түріндегі html: ", 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")
png түріндегі html:  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 ішін жылдар мен айларға жіктеген пайдалы: бір қалтадағы мың файл — ыңғайсыз, жүз мың — баяу.

Бөтен файлдарды абайлап тарату

Жүктелгенді тарату — қателесетін бөлек орын.

uploads қалтасы бойынша http.FileServer — қалыпты нәрсе, бірақ өткен сабақтағы X-Content-Type-Options: nosniff тақырыбы мұнда міндетті: онсыз браузер сіздің «png»-іңіз шын мәнінде HTML деп шешіп, оны орындауы мүмкін.

Ары қарай — жағдайға қарай. Үлкен сайттар жүктелгенді бөлек доменнен таратады, сонда кездейсоқ орындалған скрипт негізгі сайт үшін «бөтен» болып шығады да, cookie-ді де, беттерді де көрмейді. Блог үшін бұл артық, ал 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)

Әзірге пікір жоқ. Бірінші болыңыз.