Go тілі: нөлден өз блогыңызға дейін 39-сабақ (барлығы 50)
CSRF пен XSS: бәріне қойылатын үш қорғаныс
Go курсының отыз тоғызыншы сабағы. Бетіңіздегі бөтен скрипт және оқырманыңыздың атынан форма жіберетін бөтен сайт. html/template-тің орнына қарай — мәтінде, сілтемеде және script ішінде — қалай экрандайтыны және жетонның сіздің формаңызды бөтеннен қалай ажырататыны өлшенді.
Не үшін керек
Блог адамдарды ажыратуды және бөтенді қолсұқпайтын етіп сақтауды үйренді. Енді бір де бір құпиясөзді термей-ақ мұның бәрін айналып өтетін екі тесікті жабу қалды.
XSS — бетіңізге түскен бөтен скрипт. Ол оқырманның браузерінде сіздің сайтыңыздың атынан орындалады, демек оқырман істей алатынның бәрін істей алады: бетті оқу, форма жіберу, сессияны алып кету.
CSRF — сіздің сайтыңызға форма жіберетін бөтен сайт. Браузер сұранысқа оқырманыңыздың cookie-ін мойынсұнып тіркейді, ал сервер кәдімгі, толықтай «өз» сұранысын көреді.
Екі тесік те қырағылықпен емес, екі тәсілмен және үш тақырыппен емделеді.
Бірден тұтас
Жаңа қалта, go mod init sabaq36:
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
)
// tokens — әр сессияға бір жетон. Нағыз блогта бұл сессияның қасындағы
// дерекқор жолы; мұнда карта, сабақ сақтау емес, қорғаныс туралы болсын деп.
var (
mu sync.Mutex
tokens = map[string]string{}
)
func main() {
srv := httptest.NewServer(secure(routes()))
defer srv.Close()
fmt.Println("== орама қоятын тақырыптар")
for _, h := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy"} {
fmt.Printf("%-24s %s\n", h, header(srv.URL+"/form", h))
}
fmt.Println()
fmt.Println("== шығуды экрандау")
fmt.Println(get(srv.URL + "/read?title=" + url.QueryEscape(`<script>alert(1)</script>`)))
fmt.Println()
fmt.Println("== форма мен жетон")
token := tokenFrom(get(srv.URL + "/form"))
fmt.Println("формадағы жетон: ", token[:8]+"…")
fmt.Println("жетонсыз: ", post(srv.URL+"/delete", nil))
fmt.Println("бөтен жетонмен: ", post(srv.URL+"/delete", url.Values{"csrf": {"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}))
fmt.Println("өз жетонымен: ", post(srv.URL+"/delete", url.Values{"csrf": {token}}))
}
func routes() http.Handler {
mux := http.NewServeMux()
// Мақала беті: тақырып формадан, яғни бөтен адамнан келді.
mux.HandleFunc("GET /read", func(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.New("read").Parse(`<h1>{{ .Title }}</h1>`))
t.Execute(w, map[string]any{"Title": r.URL.Query().Get("title")})
})
mux.HandleFunc("GET /form", func(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.New("form").Parse(
`<form method="post" action="/delete">` +
`<input type="hidden" name="csrf" value="{{ .Token }}">` +
`<button>өшіру</button></form>`))
t.Execute(w, map[string]any{"Token": issue(sessionOf(r))})
})
mux.HandleFunc("POST /delete", func(w http.ResponseWriter, r *http.Request) {
if !valid(sessionOf(r), r.FormValue("csrf")) {
http.Error(w, "жетон келмеді", http.StatusForbidden)
return
}
fmt.Fprint(w, "өшірілді")
})
return mux
}
// secure — әр жауапқа үш тақырып қоятын орама.
func secure(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Бетке нені жүктеуге және орындауға рұқсат. default-src 'self' —
// «тек өз сайтыңнан» дегені, әрі бөтен скрипт белгілеуге түссе де
// іске қосылмайды.
w.Header().Set("Content-Security-Policy", "default-src 'self'")
// Браузер файл түрін болжамауға тиіс: text/plain делінсе, демек бұл
// скрипт емес.
w.Header().Set("X-Content-Type-Options", "nosniff")
// Бөтен сайтқа оған қай мекенжайдан келгенін толық айтпайды.
w.Header().Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
// issue сессияға жетон беріп, оны есте сақтайды. Сессияға бір жетон: ол
// иесінен құпия емес, ол бөтен сайттан құпия.
func issue(session string) string {
mu.Lock()
defer mu.Unlock()
if t, ok := tokens[session]; ok {
return t
}
raw := make([]byte, 32)
rand.Read(raw)
t := base64.RawURLEncoding.EncodeToString(raw)
tokens[session] = t
return t
}
// valid жетондарды бірдей уақытта салыстырады: кәдімгі == алғашқы сәйкес
// келмеген байтта шығып кетеді, ал жауап уақыты бойынша жетонды тереді.
func valid(session, got string) bool {
mu.Lock()
want := tokens[session]
mu.Unlock()
if want == "" || got == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1
}
func sessionOf(r *http.Request) string {
if c, err := r.Cookie("session"); err == nil {
return c.Value
}
return "қонақ"
}
func tokenFrom(page string) string {
const mark = `name="csrf" value="`
i := strings.Index(page, mark)
rest := page[i+len(mark):]
return rest[:strings.IndexByte(rest, '"')]
}
Көмекшілер — екінші файлда, helpers.go. Шығатыны:
== орама қоятын тақырыптар
Content-Security-Policy default-src 'self'
X-Content-Type-Options nosniff
Referrer-Policy same-origin
== шығуды экрандау
<h1><script>alert(1)</script></h1>
== форма мен жетон
формадағы жетон: S3tZqJ-E…
жетонсыз: 403 Forbidden — жетон келмеді
бөтен жетонмен: 403 Forbidden — жетон келмеді
өз жетонымен: 200 OK — өшірілді
Талдау
html/template орнына қарай экрандайды
Жақсы жаңалықтан бастайық: шаблондағы XSS-тен Go өзі қорғайды әрі мұны көрінгеннен ақылдырақ істейді. Ол мәннің қайда түсетінін көреді де, әртүрлі экрандайды:
<h1>{{ .Title }}</h1> -> <h1><script>alert(1)</script></h1>
<a href="{{ .URL }}">сілтеме</a> -> <a href="#ZgotmplZ">сілтеме</a>
<h1>{{ .Safe }}</h1> -> <h1><script>alert(1)</script></h1>
<script>var t = "{{ .Title }}";</script> -> <script>var t = "\u003cscript\u003ealert(1)\u003c\/script\u003e";</script>
Бірінші жол — кәдімгі мәтін: бұрыштық жақшалар < пен >-ге айналды, скрипт көрінетін жолға айналды.
Екіншісі — ең қызығы. href-ке javascript:alert(1) мекенжайы түсті, ал Go оны шығарудан бас тартты, орнына #ZgotmplZ қойды. Бұл қате де, қоқыс та емес: html/template осы жерде қауіпсіз емес мәнді осылай белгілейді. Бетте мұны көрсеңіз — сілтемеге орны емес нәрсе келген деген сөз.
Төртіншісі — <script> ішіндегі мән: ол енді JavaScript ережелерімен, < арқылы экрандалған. Бір шаблондаушы үш жерде үш түрлі нәрсе істеді, өйткені ол жолдарды жабыстырмай, белгілеуді талдайды.
Елестетіп көріңіз. Қайда келгеніңізді білетін аудармашы. Бір жерде «сәлеметсіз бе» дейді, екіншісінде «мәртебелі», үшіншісінде үндемейді — өйткені жағдайды түсінеді, жай сөз алмастырмайды.
Осыдан ереже шығады: HTML-ді жолдардан құрастырмаңыз. fmt.Sprintf("<h1>%s</h1>", title) ешқандай орын туралы білмейді әрі не болса соны кірістіреді.
template.HTML — өзіңіз ашатын тесік
Шығудың үшінші жолы: сол мән template.HTML-ге оралып, сол күйінде шығады — беттегі скрипт.
Солай ойластырылған: бұл тип «мен жауап беремін, мұнда нағыз белгілеу» дегенді білдіреді. Ол керек — біз оны іздеу туралы сабақта бояуды көрсету үшін қолдандық, — әрі ол Go-да өзіңізге XSS ашудың жалғыз жолы.
Ереже қарапайым: template.HTML-ді тек өзіңіз құрастырғанға кигізуге болады, ешқашан адамнан келгенге емес. Іздеу туралы сабақта біз сондықтан үзіндіні қолмен экрандап, тек өз белгілерімізді қайтарғанбыз.
CSRF: сіздің cookie-іңізбен келген бөтен сайт
Енді екінші тесік, әрі ол керісінше құрылған: скрипттің мүлде қажеті жоқ.
Оқырман блогыңызға кірді де, көрші қойындыда бөтен бетті ашты. Сол бетте мынау жатыр:
<form method="post" action="https://сіздің-блог/delete">
<input type="hidden" name="slug" value="dala">
</form>
<script>document.forms[0].submit()</script>
Браузер форманы жібереді әрі блогыңыздың cookie-ін тіркейді — өйткені cookie сұраныс қайдан келгеніне қарап емес, баратын мекенжайына қарап тіркеледі. Сервер кірген адамды көреді де, мақаланы мойынсұнып өшіреді.
Бірінші бөгетті сессиялар туралы сабақта қойып қойдыңыз: SameSite=Lax браузерге бөтен сайтта басталған сұраныстарға cookie-ді тіркемеуді бұйырады. Ол жақсы, бірақ тек соған сүйенуге болмайды: ол сіздің қосалқы домендеріңіз туралы ештеңе білмейді, ал ескі браузерлерде ол жоқ.
Әрі ұмытылатын салдар: Lax сілтеме бойынша кәдімгі өтулерді өткізеді. Демек, GET ештеңені өзгертпеуге тиіс — әйтпесе оқырманды <img src="сіздің-блог/delete?slug=dala"> дегенге әкелу жеткілікті.
Жетон: бөтен сайтта жоқ нәрсе
Екінші бөгет — серверде. Формаға сессияға байланған кездейсоқ жол салады да, талдағанда салыстырады:
<input type="hidden" name="csrf" value="{{ .Token }}">
Шығуда үш жағдайдың үшеуі де көрінеді: жетонсыз — 403, бөтен жетонмен — 403, өзінікімен — «өшірілді».
Бұл былай жұмыс істейді: бөтен сайт сіздің бетіңізді оқи алмайды — браузер оған басқа мекенжайдағы жауаптың мазмұнын көрсетпейді. Форманы соқыр күйінде жібере алады, ал жетонды білуі — жоқ.
Жетон оқырманның өзінен құпия емес: ол оның өз формасында жатыр. Ол бөтен беттен құпия, әрі осы жеткілікті.
Ары қарай — білген жөн егжей-тегжейлер. Жетонды әр формаға емес, сессияға жасайды: солай қарапайым, әрі ашық қойындылар жұмысын қоймайды. Оны тек өшіруде емес, күйді өзгертетін барлық әдісте тексеру керек. Ал кіргеннен кейін жетонды қайта берген дұрыс — сессияны қайта беретін себеппен.
Бірдей уақытта салыстыру
valid ішіндегі subtle.ConstantTimeCompare жолы — әшекей емес.
Жолдарға арналған кәдімгі == алғашқы сәйкес келмеген байтта салыстырудан шығып кетеді. Демек, жауап уақыты алғашқы неше байт табылғанына тәуелді, ал сол уақыт бойынша құпияны бір таңбадан теріп алады. Шабуыл ескі, ол уақыт бойынша шабуыл деп аталады, әрі оған жетон ғана емес, құпияны салыстыратын кез келген жер түседі.
subtle.ConstantTimeCompare мазмұнды жолдардың қай жерде айырылғанына тәуелсіз уақытта салыстырады. Білген жөн бір ескерту: ұзындығы әртүрлі болса, ол бірден шығып, нөл қайтарады — тұрақты уақыт ұзындығы бірдей тілімдерге уәде етілген. Бізге бұл кедергі емес: жетондарымыз әрқашан бір ұзындықта, ал басқа ұзындықтағы кіріс одан бұрын сүзіледі. Оны құпияны салыстыратын жерде жазыңыз; кәдімгі жолдар үшін оның қажеті жоқ.
Бәріне қойылатын үш тақырып
secure орамасы әр жауапқа үш тақырып қосады — міне олар шығуда:
Content-Security-Policy: default-src 'self'— бетке нені жүктеуге және орындауға рұқсат.'self'мәнімен бөтен скрипт белгілеуге қалайда түссе де іске қосылмайды. Бұл — экрандау істемей қалғандағы соңғы шеп;X-Content-Type-Options: nosniff— браузер файл түрін мазмұны бойынша болжамауға тиіс. Онсыз жүктелген «мәтіндік» файл скрипт ретінде орындалуы мүмкін;Referrer-Policy: same-origin— бөтен сайтқа оған дәл қай мекенжайдан өткенін айтпайды.
Бір орамадағы үш жол, әрі олар бүкіл блогқа бірден әсер етеді — middleware дәл сол үшін ойлап табылған.
Сабақ картасы
Өз сөзіңізбен айтыңыз
Қарамай, дауыстап немесе қағазға жауап беріңіз. Жауаптары — сабақтың соңында.
html/templateсілтемеде неге#ZgotmplZ, ал мәтінде<қойды?- Бөтен сайт неге сіздің формаңызды жібере алады, бірақ жетонды қолдан жасай алмайды?
GETнеге ештеңені өзгертпеуге тиіс?
Тапсырма
Міндетті. Блогтағы екі тесікті де жабыңыз. Орама барлық жауапқа үш тақырып қояды. Әр форма жетоны бар жасырын өріс алады, ал әр POST оны тексереді; тексеру өңдеушілерде емес, орамада тұрады. Жетонсыз форманың 403 беретініне көз жеткізіңіз.
Мұның бәрі step-24 ішінде жасалған — өзіңіз жазғаннан кейін салыстырыңыз.
Қалауыңызша.
- Тақырыбы
<script>alert(1)</script>болатын мақала жасап, беттің бастапқы кодына қараңыз. Сосын тақырыптыtemplate.HTML-ге орап, тағы қараңыз. Бұрынғысына қайтарыңыз. - Өз
/delete-іңізге форма жазылған файлды сақтап, блогқа кірген күйде оны браузерден ашыңыз. Алдымен серверде жетонсыз, сосын жетонмен. - CSP-ге
img-srcқосып, суреттерді тек өз доменіңізден рұқсат етіңіз.
Блогта бұл қайда тұрады
Осы сабақтан кейін блог көрші қойындының алдында қорғансыз болуын қояды. Үш тақырып, әр формадағы жетон және онсыз да болған экрандау — бұл сайтты адамдарға ашпас бұрын керек ең аз нәрсе.
Қарыздар. Жетон дерекқорда емес, жадта тұр: серверді қайта қосу барлық форманы құлатады. CSP кірістірілген стильдерге рұқсат етеді, өйткені олар бізде бар. Ал өз қауіптері бар файл жүктеу бөлек сабақ болады.
Жауаптар
Жауаптарды көрсету
- Себебі
html/templateбелгілеуді талдайды әрі мәннің қай орынға түсетінін біледі. Мәтінде бұрыштық жақшалар қауіпті, мекенжайда —javascript:схемасы, әрі екіншісін ол экрандамай, тұтастай қабылдамайды, орнына#ZgotmplZқояды. - Себебі браузер бөтен сайтқа сұраныс жіберуге рұқсат етеді, ал сіздің мекенжайыңыздан келген жауапты оқуға — жоқ. Жетон сіздің бетіңізде жатыр, әрі бөтен сайт оны көрмейді.
- Себебі
SameSite=Laxсілтеме бойынша кәдімгі өтулерді өткізеді, әрі кез келген<img src="...">немесе хаттағы сілтеме әрекетке айналады. Күйді текPOSTпен оның туыстары өзгертеді.
Пікірлер (0)
Пікір қалдыру үшін кіріңіз →
Әзірге пікір жоқ. Бірінші болыңыз.