Shanraq.org Shanraq.org
Транзакция және SQL-инъекция: сұраныс — жабыстырылатын жол емес
IT

Go тілі: нөлден өз блогыңызға дейін 32-сабақ (барлығы 50)

Транзакция және SQL-инъекция: сұраныс — жабыстырылатын жол емес

Go курсының отыз екінші сабағы. Дерекқорды бүтін ұстайтын екі ереже. Дерек сұраныс мәтініне жабыстырылмайды: қолдан жасалған мекенжай бір емес, үш жол қайтарады, ал параметр арқылы — нөл. Бірге болуға тиіс нәрсе транзакцияға оралады: онсыз санауыш болмаған көшу үшін өсіп кеткен.

Не үшін керек

Осы кезге дейін сабақтардағы сұраныстар қысқа әрі параметрлі болды, әрі бұл жай стиль сияқты көрінді. Бұл стиль емес.

Оқырман жіберген жол — мәтін емес, бөтен ерік. Оны сұранысқа жабыстырсаңыз, оқырман сіздің сұранысыңызға өзінікін қосып жазады. Осылай талай дерекқор құрыған, ал оны істеу тәсілі форманың бір жолына сыяды.

Екінші ереже басқа нәрсе туралы. Блогтағы бір әрекет көбіне екі сұраныстан тұрады: мақаланы көшіру және оны санауышқа жазу, ақшаны шегеру және оны есептеу. Екінші сұраныс құласа, біріншісі жасалып қойған — дерекқор өмірде болмайтын күйде қалады.

Бірден тұтас

Жаңа қалта, go mod init sabaq29, драйвер сол баяғы:

package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"

	_ "modernc.org/sqlite"
)

func main() {
	os.Remove("blog.db")
	db, err := sql.Open("sqlite", "blog.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	setup(db)

	fmt.Println("== жолдарды жабыстыру")
	fmt.Println("кәдімгі мекенжай:", bad(db, "dala"))
	fmt.Println("қолдан жасалған: ", bad(db, "dala' or '1'='1"))

	fmt.Println("== параметр")
	fmt.Println("кәдімгі мекенжай:", good(db, "dala"))
	fmt.Println("қолдан жасалған: ", good(db, "dala' or '1'='1"))

	fmt.Println("== реті")
	fmt.Println("order by ?       ->", byParam(db, "title"))
	fmt.Println("ақ тізім бойынша ->", byAllowed(db, "title"))
	fmt.Println("бөтен өріс       ->", byAllowed(db, "views; drop table articles"))
}

// bad мәнді сұраныс мәтініне жабыстырады. Былай жазуға болмайды.
func bad(db *sql.DB, slug string) string {
	q := fmt.Sprintf(`select count(*) from articles where slug = '%s'`, slug)
	var n int
	if err := db.QueryRow(q).Scan(&n); err != nil {
		return "қате: " + err.Error()
	}
	return fmt.Sprint("жол: ", n)
}

// good мәнді бөлек, мән ретінде береді.
func good(db *sql.DB, slug string) string {
	var n int
	if err := db.QueryRow(`select count(*) from articles where slug = ?`, slug).Scan(&n); err != nil {
		return "қате: " + err.Error()
	}
	return fmt.Sprint("жол: ", n)
}

func byParam(db *sql.DB, col string) string {
	return list(db, `select slug from articles order by ?`, col)
}

// byAllowed баған атын өзіміз жазған тізіммен салыстырады. Тізімде жоқ атқа
// қате түсіндірілмейді — одан бас тартады.
func byAllowed(db *sql.DB, col string) string {
	allowed := map[string]bool{"slug": true, "title": true, "views": true}
	if !allowed[col] {
		return "бас тарту: мұндай өріс жоқ"
	}
	return list(db, `select slug from articles order by `+col)
}

func list(db *sql.DB, q string, args ...any) string {
	rows, err := db.Query(q, args...)
	if err != nil {
		return "қате: " + err.Error()
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			return "қате: " + err.Error()
		}
		out = append(out, s)
	}
	if err := rows.Err(); err != nil {
		return "қате: " + err.Error()
	}
	return fmt.Sprint(out)
}

func setup(db *sql.DB) {
	if _, err := db.Exec(`create table articles (
		id    integer primary key,
		slug  text not null unique,
		title text not null,
		views integer not null default 0
	) strict`); err != nil {
		log.Fatal(err)
	}
	for _, a := range [][2]string{{"dala", "Киіз үй"}, {"salem", "Әліппе"}, {"shanyraq", "Әлем"}} {
		if _, err := db.Exec(`insert into articles (slug, title) values (?, ?)`, a[0], a[1]); err != nil {
			log.Fatal(err)
		}
	}
}

Шығатыны:

== жолдарды жабыстыру
кәдімгі мекенжай: жол: 1
қолдан жасалған:  жол: 3
== параметр
кәдімгі мекенжай: жол: 1
қолдан жасалған:  жол: 0
== реті
order by ?       -> [dala salem shanyraq]
ақ тізім бойынша -> [dala shanyraq salem]
бөтен өріс       -> бас тарту: мұндай өріс жоқ

Талдау

Бір емес, үш жол

Шығудың алғашқы екі жолына қараңыз. dala мекенжайы бір мақала тапты — дұрыс. dala' or '1'='1 мекенжайы үшеуін, яғни бәрін тапты.

Не болғанын сұранысты қолмен құрап көрсек, көрінеді:

select count(*) from articles where slug = 'dala' or '1'='1'

Тырнақша мекенжайды жауып тастады, ары қарай әрқашан ақиқат шарт кетті. Мекенжайды тексеру мүлде жоғалды. Дәл осылай кіру формасына or 1=1 қосып, құпиясөзсіз кіреді, ал ; арқылы шарт емес, тұтас екінші сұранысты қосып жазуға болады.

Елестетіп көріңіз. Толтырылған бланк, оған артық жол жазып қойған — әрі ол бланктің өз жалғасы болып оқылады. Шет жағындағы белгі емес, басылып шыққандай тармақ.

? — қою емес, бөлек жөнелтілім

Шығудың үшінші және төртінші жолы: сол қолдан жасалған мекенжай ? арқылы нөл береді.

Қате емес, нөл — мұны дұрыс түсіну маңызды. Дерекқор сұраныс пен мәнді бөлек алды. Сұраныс мәтіні мән оған жетпей тұрып талданған, сондықтан мәннің ішіндегі тырнақша ешнәрсені жаппайды: олар жай әріп. Дерекқор dala' or '1'='1 деген мекенжайы бар мақаланы адал іздеді де, таппады.

Осыдан ерекшеліксіз ереже шығады: сырттан келгеннің бәрі ? арқылы жүреді. «Қауіпті сияқты болса» емес, «жол болса» емес — бәрі. Тырнақшаны қолмен экрандаудың қажеті жоқ әрі керегі жоқ: бұл — драйвердің жұмысы, ол оны жақсырақ істейді.

Postgres-те мән орындары басқаша жазылады — $1, $2 — бірақ мәні сол.

? арқылы нені қоюға болмайды

Шығудың бесінші жолы — бүкіл сабақтағы ең қулық жол:

order by ?       -> [dala salem shanyraq]

Қате жоқ. Реттеу де жоқ: мекенжайлар қалай жатса, солай шықты. Ақ тізім арқылы сол title басқа рет береді — [dala shanyraq salem], — әрі дұрысы сол.

Себебі қорғаныстағымен бір. Мән — дерек, ал баған аты — сұраныс мәтінінің бөлігі. Дерекқор order by 'title' алды — барлық жол үшін бірдей тұрақты-жол бойынша реттеу. Ол бойынша реттеудің мағынасы жоқ, сондықтан рет бұрынғыдай қалды.

Айтпақшы, реттеу туралы: SQLite әдепкіде жолдарды тілдің әліпбиі бойынша емес, таңба кодтары бойынша салыстырады. Латын үшін бұл шамамен бірдей, қазақ тілі үшін жоқ: Қ мен Ә кодтауда қазақ әліпбиіндегі орнынан алыс тұр. Сондықтан жоғарыдағы шығуда «Киіз үй» «Әлем» мен «Әліппеден» бұрын тұр. Адамға дұрыс рет — бөлек жұмыс, дерекқорда ол collation деп аталады.

? арқылы кесте атын да, баған атын да, asc/desc-ті де, кейбір дерекқорда limit-ті де қоюға болмайды. Дәл осы жерде «бәрін параметрмен істеймін» дейтін адамдарда инъекция пайда болады.

Тапқырлықтың орнына ақ тізім

Баған аты сырттан келсе — ?sort=title сілтемесінен, — оны экрандауға да, тазалауға да болмайды. Оны тек өзіңіз жазған тізіммен салыстыруға болады:

allowed := map[string]bool{"slug": true, "title": true, "views": true}
	if !allowed[col] {
		return "бас тарту: мұндай өріс жоқ"
	}
	return list(db, `select slug from articles order by `+col)
}

Шығудың соңғы жолы views; drop table articles дегенге бас тартқанды көрсетеді. Бұл тексеруде не жоқ екеніне назар аударыңыз: қауіптіні болжау әрекеті. Тізімдегіге рұқсат, қалғанына жоқ. Үш сөзден тұратын тізім кез келген тазалаудан сенімдірек, өйткені ол бөтен адам не ойлап табатыны туралы сіздің қиялыңызға тәуелді емес.

Транзакция: бәрі немесе ештеңе

Сабақтың екінші ережесі. Екі сұраныстан тұратын әрекетті алайық: мақаланы жаңа мекенжайға көшіру және оны санауышқа белгілеу.

func move(db *sql.DB, from, to string) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	if _, err := tx.Exec(`update articles set views = views + 1 where slug = 'dala'`); err != nil {
		return err
	}
	if _, err := tx.Exec(`update articles set slug = ? where slug = ?`, to, from); err != nil {
		return err
	}
	return tx.Commit()
}

Алдымен Begin болмағанда не болатынын көрейік — сол екі db.Exec қатарынан. Алдымен бос емес мекенжайға, сосын бос мекенжайға көшіреміз:

дейін: dala=0 shanyraq=0
көшіру: constraint failed: UNIQUE constraint failed: articles.slug (2067)
кейін: dala=1 shanyraq=0
көшіру: <nil>
кейін: dala=2 koktem=0

Санауыш болмаған көшу үшін 1-ге дейін өсті: бірінші сұраныс өтті, екіншісі құлады. Соңында бір ғана шынайы көшуде 2. Дерекқор сынған жоқ, одан жаманы: ол өтірік айтады.

Енді транзакциямен, әрекеттер реті сол:

дейін: dala=0 shanyraq=0
көшіру: constraint failed: UNIQUE constraint failed: articles.slug (2067)
кейін: dala=0 shanyraq=0
көшіру: <nil>
кейін: dala=1 koktem=0

Сәтсіздіктен кейін dala=0 — санауыш тиылмаған, мақала орнында. Сәттіліктен кейін dala=1. Транзакция — дерекқордың уәдесі: не екі сұраныс, не бірде-бірі.

defer tx.Rollback() — барлық шығуға бір жол

Функциядан төрт жерде шығуға болады, солардың үшеуінде транзакцияны артқа қайтару керек. Әр return алдына tx.Rollback() жазу — бір күні ұмытудың сенімді жолы.

Сондықтан Begin-нен кейін бірден бір жол жазады. Мұның жұмыс істеу себебі: сәтті Commit-тен кейінгі қайтару ештеңе істемейді. Транзакция аяқталып қойған, Rollback sql.ErrTxDone қайтарады, әрі бұл қате емес, «кеш, бәрі бітті» деген хабар. Мәнді біз әдейі тексермейміз.

Әрі ұмытуға оңай нәрсе: транзакция өмір бойы пулдан бір қосылымды алып тұрады. move ішінде db-ға жүгінуге болмайды — тек tx-ке; db арқылы кеткен сұраныс басқа қосылыммен жүреді, аяқталмаған өзгерістерді көрмейді әрі өз-өзінің артында кезекке тұруы мүмкін. Ұзақ транзакция қосылымды ұстап тұрады да, ешкімге бермейді.

Екі жазушы: database is locked

SQLite-та жазушы біреу-ақ бола алады. Тексерейік: бір файлға екі бөлек қосылымнан екі транзакция.

бірінші транзакция жазады: болды
екінші транзакция жазады: database is locked (5) (SQLITE_BUSY)
нәтиже: 2

Екіншісі күтпей, бірден бас тарту алды. Біріншісі өзінікін тыныш жазды, екіншісінің +100-і жоғалды — әрі дұрыс, оны ешкім растаған жоқ.

Оқырман кез келген санда бола береді. Бір ғана автор жазатын блог үшін бұл шектеу емес; жазушылар көбейгенде, бұл транзакциялар бір-біріне бұлай дөрекі кедергі жасамайтын Postgres-ке көшуге себеп болады.

Сабақ картасы

Сабақ картасы: мән сұраныстан бөлек жүреді, ал екі әрекет бірге

Өз сөзіңізбен айтыңыз

Қарамай, дауыстап немесе қағазға жауап беріңіз. Жауаптары — сабақтың соңында.

  1. Кодыңызда ештеңені экрандамаса да, ? инъекциядан неге қорғайды?
  2. order by ? не қайтарады әрі бұл неге қатеден қауіптірек?
  3. Әрі қарай Commit болатын жерде де defer tx.Rollback() не үшін қойылады?

Тапсырма

Міндетті. Блогыңыздан мән сұранысқа fmt.Sprintf немесе + арқылы түсетін барлық жерді тауып, оларды ?-ке көшіріңіз. Сосын мақалалар тізімін реттелетін етіңіз: /?sort=title және /?sort=updated, баған аты — ақ тізім арқылы, белгісіз мән үнсіз әдепкі реттеуді береді.

Реттеу step-17 ішінде жасалып қойған — өзіңіз жазғаннан кейін салыстырыңыз.

Қалауыңызша.

  • deletions кестесін ашып, мақаланы оған жазумен бірге өшіріңіз — бір транзакциямен. Екінші жартысын әдейі сындырып, мақаланың орнында қалғанына көз жеткізіңіз.
  • Get-ке x' or '1'='1 мекенжайын беретін және ErrNotFound талап ететін тест жазыңыз. Мұндай тест келесі түзетуде жабыстырудың қайта оралуын ұстайды.
  • Транзакция ішінде tx-тің орнына db-ға жүгінсе не болатынын өлшеңіз.

Блогта бұл қайда тұрады

Блогта жабыстыру жоқ — параметрлер ең бірінші сұраныстан бері тұр. Оның есесіне тізімнің реттелуі пайда болады, әрі бұл — баған аты сырттан келетін алғашқы жер.

Қарыздар. Транзакцияны әзірге шындап қолданатын жер жоқ: олар шынымен тег туралы сабақта керек болады, онда бір мақала бірден екі кестеге жазылады. Ал Update әлі де барлық өрісті бірден жазады.

Жауаптар

Жауаптарды көрсету
  1. Себебі мән сұраныс мәтініне мүлде түспейді: дерекқор талданған сұранысты және мәнді бөлек алады. Мәннің ішіндегі тырнақша SQL-дің бөлігіне айналмай, әріп болып қала береді.
  2. Жолдар қалай жатса, сол ретті — яғни реттелмейді. Бұл қатеден қауіптірек, өйткені бағдарлама құламайды: дұрыс емес ретті байқамай қалу оңай, ал оны жабыстырумен «түзетуге» тырысу инъекция ашады.
  3. Себебі функциядан шығатын жер бірнешеу, ал қайтаруды бір рет ұмытсаңыз жетеді. Сәтті Commit-тен кейін бұл Rollback ештеңе істемейді әрі sql.ErrTxDone қайтарады.

Дереккөздер

Мәтінде қате не теру қатесі кездессе, бізге айтыңыз

Тапсырманы тексеру

Алдымен VS Code-та шешіп, іске қосыңыз — редактор қатені сол жерде көрсетеді. Дайын шешімді осында қойыңыз. Тексеретін — модель: ол қатені атап көрсетеді, бірақ дайын жауапты бермейді.

Тексеру үшін кіру керек. Кіру

Пікірлер (0)

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