Shanraq.org Shanraq.org
CSRF и XSS: три защиты, которые ставят всем
IT

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>&lt;script&gt;alert(1)&lt;/script&gt;</h1>

== форма и жетон
жетон в форме:       S3tZqJ-E…
без жетона:          403 Forbidden — жетон не подошёл
с чужим жетоном:     403 Forbidden — жетон не подошёл
со своим жетоном:    200 OK — удалено

Разбор

html/template экранирует по месту

Начнём с хорошей новости: от XSS в шаблонах Go защищает сам, и делает это умнее, чем кажется. Он смотрит, куда попадает значение, и экранирует по-разному:

<h1>{{ .Title }}</h1>                      -> <h1>&lt;script&gt;alert(1)&lt;/script&gt;</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>

Первая строка — обычный текст: угловые скобки превратились в &lt; и &gt;, и скрипт стал видимой строкой.

Вторая — самое интересное. В 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 и придумали.

Карта урока

Карта урока: экранирование, жетон и заголовки

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

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

  1. Почему html/template в ссылке подставил #ZgotmplZ, а в тексте — &lt;?
  2. Почему чужой сайт может отправить вашу форму, но не может подделать жетон?
  3. Почему GET не должен ничего менять?

Задание

Обязательное. Закройте обе дыры в блоге. Обёртка ставит три заголовка на все ответы. Каждая форма получает скрытое поле с жетоном, а каждый POST его проверяет; проверка живёт в обёртке, а не в обработчиках. Убедитесь, что форма без жетона отвечает 403.

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

По желанию.

  • Заведите статью с заголовком <script>alert(1)</script> и посмотрите на исходный код страницы. Потом оберните заголовок в template.HTML и посмотрите ещё раз. Верните как было.
  • Сохраните у себя файл с формой на ваш /delete и откройте его в браузере, пока вы вошли в блог. Сначала без жетона на сервере, потом с ним.
  • Добавьте в CSP img-src и разрешите картинки только со своего домена.

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

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

Долги. Жетон живёт в памяти, а не в базе: перезапуск сервера сбросит все формы. CSP разрешает встроенные стили, потому что они у нас есть. И загрузка файлов, у которой свои опасности, будет отдельным уроком.

Ответы

Показать ответы
  1. Потому что html/template разбирает разметку и знает место, куда попадает значение. В тексте опасны угловые скобки, в адресе — схема javascript:, и второе он не экранирует, а отвергает целиком, подставляя #ZgotmplZ.
  2. Потому что отправить запрос браузер чужому сайту позволяет, а прочитать ответ с вашего адреса — нет. Жетон лежит в вашей странице, и чужой сайт его не видит.
  3. Потому что SameSite=Lax пропускает обычные переходы по ссылке, и любой <img src="..."> или ссылка в письме превращается в действие. Меняют состояние только POST и его собратья.

Источники

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

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

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

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

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

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