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

محافظت در برابر CSRF (CSRF Protection)

در این بخش، نحوه پیاده‌سازی محافظت در برابر CSRF (CSRF Protection) را بررسی می‌کنیم. این شامل توکن CSRF (CSRF Token)، میان‌افزار محافظت (Protection Middleware) و اعتبارسنجی درخواست (Request Validation) می‌شود.

برای شروع، بیایید یک میان‌افزار (Middleware) برای تولید توکن (Token Generation) ایجاد کنیم:

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

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

کوکی‌های SameSite

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

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

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

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

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

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

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

از بین دو بسته، ما در این کتاب از 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 خود را باز کنید و یک تابع میان‌افزار جدید 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 محافظت شود، فرم Signup ما است که در بخش nav.tmpl ما قرار دارد و می‌تواند در هر صفحه‌ای از برنامه ما ظاهر شود. بنابراین، به همین دلیل، ما باید از میان‌افزار noSurf() خود در همه مسیرهای برنامه خود استفاده کنیم (به جز GET /static/).

بنابراین، فایل cmd/web/routes.go را به‌روزرسانی کنید تا این میان‌افزار noSurf() را به زنجیره میان‌افزار 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)
}

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

10.07-01.png

برای کارکردن ارسال فرم‌ها، باید از تابع nosurf.Token() استفاده کنیم تا توکن CSRF را دریافت کرده و آن را به یک فیلد مخفی csrf_token در هر یک از فرم‌های خود اضافه کنیم. بنابراین گام بعدی این است که یک فیلد جدید CSRFToken به ساختار 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 را به طور خودکار از طریق کمک‌کننده newTemplateData() به داده‌های قالب اضافه کنیم. این به این معناست که هر بار که یک صفحه را رندر می‌کنیم، در دسترس قالب‌های ما خواهد بود.

لطفاً فایل 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>Signup</button>
            </form>
        {{else}}
            <a href='/user/signup'>Signup</a>
            <a href='/user/login'>Login</a>
        {{end}}
    </div>
</nav>
{{end}}

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

10.07-02.png

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


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

تنظیم 'Strict' برای SameSite

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

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

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

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

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

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

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

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

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

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

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

واژه‌نامه اصطلاحات فنی

اصطلاح فارسی معادل انگلیسی توضیح
محافظت در برابر CSRF CSRF Protection محافظت از حملات جعل درخواست
توکن CSRF CSRF Token کد امنیتی یکتا
میان‌افزار محافظت Protection Middleware کد واسط امنیتی
اعتبارسنجی درخواست Request Validation بررسی صحت درخواست
میان‌افزار Middleware کد واسط پردازش درخواست
تولید توکن Token Generation ایجاد کد امنیتی
حمله CSRF CSRF Attack حمله جعل درخواست
امنیت فرم Form Security محافظت از فرم‌های ورودی
اعتبارسنجی توکن Token Validation بررسی صحت کد امنیتی
درخواست جعلی Forged Request درخواست غیرمجاز