Let's Go احراز هویت کاربر › محافظت در برابر CSRF
قبلی · فهرست · بعدی
فصل ۱۰.۷.

محافظت در برابر CSRF

در این فصل به نحوه محافظت از برنامه خود در برابر حملات جعل درخواست بین سایتی (CSRF) می‌پردازیم.

اگر با اصول CSRF آشنا نیستید، این نوعی حمله است که در آن یک وب‌سایت مخرب شخص ثالث درخواست‌های HTTP تغییردهنده وضعیت را به وب‌سایت شما ارسال می‌کند. توضیح خوبی از حمله CSRF پایه را می‌توانید اینجا پیدا کنید.

در برنامه ما، خطر اصلی این است:

علاوه بر حملات CSRF ‘سنتی’ مانند بالا (که در آن یک درخواست با امتیازات یک کاربر وارد شده پردازش می‌شود)، برنامه شما همچنین ممکن است در معرض خطر حملات CSRF ورود و خروج باشد.

کوکی‌های SameSite

یکی از راه‌های کاهش خطر که می‌توانیم برای جلوگیری از حملات CSRF انجام دهیم این است که مطمئن شویم ویژگی SameSite به درستی روی کوکی نشست ما تنظیم شده است.

به طور پیش‌فرض بسته alexedwards/scs که استفاده می‌کنیم همیشه SameSite=Lax را روی کوکی نشست تنظیم می‌کند. این به این معنی است که کوکی نشست ارسال نخواهد شد توسط مرورگر کاربر برای هر درخواست بین سایتی با متدهای HTTP POST، PUT یا DELETE.

تا زمانی که برنامه ما از متد POST برای هر درخواست HTTP تغییردهنده وضعیت استفاده می‌کند (مانند آنچه برای ارسال فرم‌های ورود، ثبت‌نام، خروج و ایجاد snippet انجام می‌دهیم)، این به این معنی است که کوکی نشست برای این درخواست‌ها ارسال نخواهد شد اگر از وب‌سایت دیگری بیایند — در نتیجه از حمله CSRF جلوگیری می‌کند.

با این حال، ویژگی SameSite هنوز نسبتاً جدید است و فقط توسط ۹۶٪ از مرورگرها در سراسر جهان به طور کامل پشتیبانی می‌شود. بنابراین، اگرچه چیزی است که می‌توانیم (و باید) به عنوان یک اقدام دفاعی استفاده کنیم، نمی‌توانیم برای همه کاربران به آن تکیه کنیم.

کاهش خطر مبتنی بر توکن

برای کاهش خطر CSRF برای همه کاربران، همچنین باید نوعی بررسی توکن پیاده‌سازی کنیم. مانند مدیریت نشست و hash کردن رمز عبور، در مورد این موضوع چیزهای زیادی وجود دارد که می‌توانید اشتباه انجام دهید… بنابراین احتمالاً امن‌تر است که از یک بسته شخص ثالث آزمایش شده استفاده کنیم به جای پیاده‌سازی خودمان.

دو بسته محبوب برای متوقف کردن حملات CSRF در برنامه‌های وب Go عبارتند از gorilla/csrf و justinas/nosurf. هر دو تقریباً همان کار را انجام می‌دهند، با استفاده از الگوی double-submit cookie برای جلوگیری از حملات. در این الگو یک توکن CSRF تصادفی تولید می‌شود و در یک کوکی CSRF به کاربر ارسال می‌شود. سپس این توکن CSRF به یک فیلد مخفی در هر فرم HTML که به طور بالقوه در برابر CSRF آسیب‌پذیر است اضافه می‌شود. وقتی فرم ارسال می‌شود، هر دو بسته از middleware استفاده می‌کنند تا بررسی کنند که مقدار فیلد مخفی و مقدار کوکی مطابقت دارند.

از بین دو بسته، ما در این کتاب از justinas/nosurf استفاده خواهیم کرد. من آن را ترجیح می‌دهم عمدتاً به این دلیل که خودکفا است و هیچ وابستگی اضافی ندارد. اگر همراه با ما پیش می‌روید، می‌توانید آخرین نسخه را به این صورت نصب کنید:

$ go get github.com/justinas/nosurf@v1
go: downloading github.com/justinas/nosurf v1.1.1
go get: added github.com/justinas/nosurf v1.1.1

استفاده از بسته nosurf

برای استفاده از justinas/nosurf، فایل cmd/web/middleware.go خود را باز کنید و یک تابع middleware جدید noSurf() به این صورت ایجاد کنید:

File: cmd/web/middleware.go
package main

import (
    "fmt"
    "net/http"

    "github.com/justinas/nosurf" // New import
)

...

// Create a NoSurf middleware function which uses a customized CSRF cookie with
// the Secure, Path and HttpOnly attributes set.
func noSurf(next http.Handler) http.Handler {
    csrfHandler := nosurf.New(next)
    csrfHandler.SetBaseCookie(http.Cookie{
        HttpOnly: true,
        Path:     "/",
        Secure:   true,
    })

    return csrfHandler
}

یکی از فرم‌هایی که باید از حملات CSRF محافظت کنیم فرم خروج ما است که در partial nav.tmpl ما گنجانده شده است و به طور بالقوه می‌تواند در هر صفحه‌ای از برنامه ما ظاهر شود. بنابراین، به همین دلیل، باید از middleware noSurf() خود در همه مسیرهای برنامه خود استفاده کنیم (به جز GET /static/).

پس، بیایید فایل cmd/web/routes.go را به‌روزرسانی کنیم تا این middleware noSurf() را به زنجیره middleware dynamic که قبلاً ساختیم اضافه کنیم:

File: cmd/web/routes.go
package main

...

func (app *application) routes() http.Handler {
     mux := http.NewServeMux()

    fileServer := http.FileServer(http.Dir("./ui/static/"))
    mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))

    // Use the nosurf middleware on all our 'dynamic' routes.
    dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf)

    mux.Handle("GET /{$}", dynamic.ThenFunc(app.home))
    mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView))
    mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup))
    mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost))
    mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin))
    mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost))

    protected := dynamic.Append(app.requireAuthentication)

    mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate))
    mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost))
    mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost))

    standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)
    return standard.Then(mux)
}

در این مرحله، ممکن است بخواهید برنامه را راه‌اندازی کنید و سعی کنید یکی از فرم‌ها را ارسال کنید. وقتی این کار را انجام می‌دهید، درخواست باید توسط middleware noSurf() رهگیری شود و باید یک پاسخ 400 Bad Request دریافت کنید.

10.07-01.png

برای اینکه ارسال فرم‌ها کار کند، باید از تابع nosurf.Token() استفاده کنیم تا توکن CSRF را دریافت کنیم و آن را به یک فیلد مخفی csrf_token در هر یک از فرم‌های خود اضافه کنیم. پس مرحله بعدی اضافه کردن یک فیلد جدید CSRFToken به struct templateData ما است:

File: cmd/web/templates.go
package main

import (
    "html/template"
    "path/filepath"
    "time"

    "snippetbox.alexedwards.net/internal/models"
)

type templateData struct {
    CurrentYear     int
    Snippet         models.Snippet
    Snippets        []models.Snippet
    Form            any
    Flash           string
    IsAuthenticated bool
    CSRFToken       string // Add a CSRFToken field.
}

...

و چون فرم خروج به طور بالقوه می‌تواند در هر صفحه‌ای ظاهر شود، منطقی است که توکن CSRF را به طور خودکار از طریق helper newTemplateData() خود به داده‌های template اضافه کنیم. این به این معنی است که هر بار که یک صفحه را رندر می‌کنیم در دسترس templateهای ما خواهد بود.

لطفاً بروید و فایل cmd/web/helpers.go را به این صورت به‌روزرسانی کنید:

File: cmd/web/helpers.go
package main

import (
    "bytes"
    "errors"
    "fmt"
    "net/http"
    "time"

    "github.com/go-playground/form/v4"
    "github.com/justinas/nosurf" // New import
)

...

func (app *application) newTemplateData(r *http.Request) templateData {
    return templateData{
        CurrentYear:     time.Now().Year(),
        Flash:           app.sessionManager.PopString(r.Context(), "flash"),
        IsAuthenticated: app.isAuthenticated(r),
        CSRFToken:       nosurf.Token(r), // Add the CSRF token.
    }
}

...

در نهایت، باید همه فرم‌های برنامه خود را به‌روزرسانی کنیم تا این توکن CSRF را در یک فیلد مخفی شامل شوند.

به این صورت:

File: ui/html/pages/create.tmpl
{{define "title"}}Create a New Snippet{{end}}

{{define "main"}}
<form action='/snippet/create' method='POST'>
    <!-- Include the CSRF token -->
    <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
    <div>
        <label>Title:</label>
        {{with .Form.FieldErrors.title}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='text' name='title' value='{{.Form.Title}}'>
    </div>
    <div>
        <label>Content:</label>
        {{with .Form.FieldErrors.content}}
            <label class='error'>{{.}}</label>
        {{end}}
        <textarea name='content'>{{.Form.Content}}</textarea>
    </div>
    <div>
        <label>Delete in:</label>
        {{with .Form.FieldErrors.expires}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year
        <input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week
        <input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day
    </div>
    <div>
        <input type='submit' value='Publish snippet'>
    </div>
</form>
{{end}}
File: ui/html/pages/login.tmpl
{{define "title"}}Login{{end}}

{{define "main"}}
<form action='/user/login' method='POST' novalidate>
    <!-- Include the CSRF token -->
    <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
    {{range .Form.NonFieldErrors}}
        <div class='error'>{{.}}</div>
    {{end}}
    <div>
        <label>Email:</label>
        {{with .Form.FieldErrors.email}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='email' name='email' value='{{.Form.Email}}'>
    </div>
    <div>
        <label>Password:</label>
        {{with .Form.FieldErrors.password}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='password'>
    </div>
    <div>
        <input type='submit' value='Login'>
    </div>
</form>
{{end}}
File: ui/html/pages/signup.tmpl
{{define "title"}}Signup{{end}}

{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
    <!-- Include the CSRF token -->
    <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
    <div>
        <label>Name:</label>
        {{with .Form.FieldErrors.name}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='text' name='name' value='{{.Form.Name}}'>
    </div>
    <div>
        <label>Email:</label>
        {{with .Form.FieldErrors.email}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='email' name='email' value='{{.Form.Email}}'>
    </div>
    <div>
        <label>Password:</label>
        {{with .Form.FieldErrors.password}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='password'>
    </div>
    <div>
        <input type='submit' value='Signup'>
    </div>
</form>
{{end}}
File: ui/html/partials/nav.tmpl
{{define "nav"}}
<nav>
    <div>
        <a href='/'>Home</a>
         {{if .IsAuthenticated}}
            <a href='/snippet/create'>Create snippet</a>
        {{end}}
    </div>
    <div>
        {{if .IsAuthenticated}}
            <form action='/user/logout' method='POST'>
                <!-- Include the CSRF token -->
                <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
                <button>Logout</button>
            </form>
        {{else}}
            <a href='/user/signup'>Signup</a>
            <a href='/user/login'>Login</a>
        {{end}}
    </div>
</nav>
{{end}}

بروید و برنامه را دوباره اجرا کنید، سپس منبع (view source) یکی از فرم‌ها را ببینید. باید ببینید که اکنون یک توکن CSRF در یک فیلد مخفی دارد، به این صورت:

10.07-02.png

و اگر سعی کنید فرم‌ها را ارسال کنید، اکنون باید دوباره به درستی کار کند.


اطلاعات اضافی

تنظیم SameSite ‘Strict’

اگر می‌خواهید، می‌توانید کوکی نشست را تغییر دهید تا از تنظیم SameSite=Strict به جای (پیش‌فرض) SameSite=Lax استفاده کند. به این صورت:

sessionManager := scs.New()
sessionManager.Cookie.SameSite = http.SameSiteStrictMode

اما مهم است که بدانید استفاده از SameSite=Strict ارسال کوکی نشست توسط مرورگر کاربر را برای همه استفاده‌های بین سایتی مسدود می‌کند — از جمله درخواست‌های امن (safe) با متدهای HTTP مانند GET و HEAD.

در حالی که ممکن است حتی امن‌تر به نظر برسد (و هست!)، نقطه ضعف این است که کوکی نشست ارسال نخواهد شد وقتی کاربر روی یک لینک به برنامه شما از وب‌سایت دیگری کلیک می‌کند. به نوبه خود، این به این معنی است که برنامه شما در ابتدا کاربر را به عنوان ‘لاگین نشده’ در نظر می‌گیرد حتی اگر یک نشست فعال حاوی مقدار "authenticatedUserID" آن‌ها داشته باشند.

پس اگر برنامه شما به طور بالقوه وب‌سایت‌های دیگری به آن لینک می‌دهند (یا لینک‌هایی به آن در ایمیل‌ها یا سرویس‌های پیام‌رسانی خصوصی به اشتراک گذاشته می‌شود)، پس SameSite=Lax به طور کلی تنظیم مناسب‌تری است.

کوکی‌های SameSite و TLS 1.3

قبلاً در این فصل گفتم که نمی‌توانیم فقط به ویژگی cookie SameSite برای جلوگیری از حملات CSRF تکیه کنیم، چون توسط همه مرورگرها به طور کامل پشتیبانی نمی‌شود.

اما یک استثنا برای این قانون وجود دارد، به دلیل این واقعیت که هیچ مرورگری وجود ندارد که TLS 1.3 را پشتیبانی کند و از cookieهای SameSite پشتیبانی نکند.

به عبارت دیگر، اگر TLS 1.3 را به عنوان حداقل نسخه پشتیبانی شده در پیکربندی TLS برای سرور خود تنظیم کنید، پس همه مرورگرهایی که قادر به استفاده از برنامه شما هستند از cookieهای SameSite پشتیبانی خواهند کرد.

tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS13,
}

تا زمانی که فقط درخواست‌های HTTPS را به برنامه خود اجازه می‌دهید و TLS 1.3 را به عنوان حداقل نسخه TLS اعمال می‌کنید، نیازی به انجام هیچ کاهش اضافی در برابر حملات CSRF ندارید (مانند استفاده از بسته justinas/nosurf). فقط مطمئن شوید که همیشه: