Go: с нуля до своего блога Урок 39 из 50
CSRF и XSS: три защиты, которые ставят всем
Тридцать девятый урок курса по Go. Чужой скрипт на вашей странице и чужой сайт, отправляющий форму от имени вашего читателя. Измерено, как html/template экранирует по месту — в тексте, в ссылке и внутри script, — и как жетон отличает вашу форму от чужой.
Зачем это нужно
Блог научился различать людей и хранить чужое неприкосновенным. Осталось закрыть две дыры, через которые всё это обходят, не подбирая ни одного пароля.
XSS — чужой скрипт, попавший на вашу страницу. Он выполняется в браузере читателя от имени вашего сайта, а значит, может всё, что может читатель: прочитать страницу, отправить форму, увести сессию.
CSRF — чужой сайт, отправляющий форму на ваш. Браузер послушно приложит к запросу печенье вашего читателя, и сервер увидит обычный, полностью «свой» запрос.
Обе дыры лечатся не бдительностью, а двумя приёмами и тремя заголовками.
Сразу целиком
Новая папка, 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, выводится как есть — скрипт на странице.
Так и задумано: этот тип означает «я отвечаю, здесь настоящая разметка». Он нужен — мы им пользовались в уроке про поиск, чтобы показать подсветку, — и он же единственный способ открыть себе XSS в Go.
Правило простое: template.HTML можно надевать только на то, что вы сами собрали, и никогда — на то, что пришло от человека. В уроке про поиск мы поэтому экранировали фрагмент руками и возвращали обратно только собственные метки.
CSRF: чужой сайт с вашим печеньем
Теперь вторая дыра, и она устроена наоборот: скрипт не нужен вовсе.
Читатель вошёл в ваш блог и в соседней вкладке открыл чужую страницу. На той странице лежит:
<form method="post" action="https://ваш-блог/delete">
<input type="hidden" name="slug" value="dala">
</form>
<script>document.forms[0].submit()</script>
Браузер отправит форму и приложит печенье вашего блога — потому что печенье прикладывается по адресу назначения, а не по тому, откуда пришёл запрос. Сервер увидит вошедшего человека и послушно удалит статью.
Первый заслон вы уже поставили в уроке про сессии: SameSite=Lax велит браузеру не прикладывать печенье к запросам, начатым на чужом сайте. Он хорош, но полагаться только на него нельзя: он ничего не знает про ваши же поддомены, и в старых браузерах его нет.
И следствие, о котором забывают: Lax пропускает обычные переходы по ссылке. Значит, GET не должен ничего менять — иначе достаточно заманить читателя на <img src="ваш-блог/delete?slug=dala">.
Жетон: то, чего у чужого сайта нет
Второй заслон — на сервере. В форму кладут случайную строку, привязанную к сессии, и при разборе сверяют:
<input type="hidden" name="csrf" value="{{ .Token }}">
В выводе видно все три случая: без жетона — 403, с чужим жетоном — 403, со своим — «удалено».
Работает это потому, что чужой сайт не может прочитать вашу страницу: браузер не даст ему увидеть содержимое ответа с другого адреса. Отправить форму вслепую он может, а узнать жетон — нет.
Жетон не секрет от самого читателя: он лежит в его же форме. Он секрет от чужой страницы, и этого достаточно.
Дальше — детали, о которых стоит знать. Жетон делают на сессию, а не на каждую форму: так проще, и открытые вкладки не перестают работать. Проверять его надо на всех методах, которые меняют состояние, а не только на удалении. И после входа жетон лучше выдать заново — по той же причине, по которой заново выдают сессию.
Сравнивать за одинаковое время
Строка subtle.ConstantTimeCompare в valid — не украшение.
Обычное == для строк выходит из сравнения на первом же несовпавшем байте. Значит, время ответа зависит от того, сколько первых байтов угадано, и по этому времени секрет подбирают по одному символу. Атака старая, называется атакой по времени, и на неё ловятся не только жетоны, но и любые сравнения секретов.
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)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.