Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
313 lines
9.2 KiB
Go
313 lines
9.2 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package nosurfx
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/ory/x/httpx"
|
|
"github.com/ory/x/logrusx"
|
|
|
|
"github.com/ory/kratos/text"
|
|
|
|
"github.com/ory/kratos/driver/config"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/ory/herodot"
|
|
"github.com/ory/nosurf"
|
|
"github.com/ory/x/randx"
|
|
)
|
|
|
|
func ErrInvalidCSRFToken() *herodot.DefaultError {
|
|
return herodot.ErrForbidden().
|
|
WithID(text.ErrIDCSRF).
|
|
WithError("the request was rejected to protect you from Cross-Site-Request-Forgery").
|
|
WithDetail("docs", "https://www.ory.com/docs/troubleshooting/csrf").
|
|
WithReason("Please retry the flow and optionally clear your cookies. The request was rejected to protect you from Cross-Site-Request-Forgery (CSRF) which could cause account takeover, leaking personal information, and other serious security issues.")
|
|
}
|
|
|
|
func ErrGone() *herodot.DefaultError {
|
|
return &herodot.DefaultError{
|
|
CodeField: http.StatusGone,
|
|
StatusField: http.StatusText(http.StatusGone),
|
|
ErrorField: "The requested resource is no longer available because it has expired or is otherwise invalid.",
|
|
}
|
|
}
|
|
|
|
const (
|
|
noCookie = "The HTTP Cookie Header is empty or not set."
|
|
cookieMissing = "The HTTP Cookie Header was set but did not include the anti-CSRF cookie."
|
|
tokenNotSent = "The anti-CSRF cookie was found but the CSRF token was not included in the HTTP request body (" + nosurf.CookieName + ") nor in the HTTP Header (" + nosurf.HeaderName + ")."
|
|
tokenMismatch = "The HTTP Cookie Header was set and a CSRF token was sent but they do not match. We recommend deleting all cookies for this domain and retrying the flow."
|
|
)
|
|
|
|
func ErrInvalidCSRFTokenAJAX() *herodot.DefaultError {
|
|
return ErrInvalidCSRFToken().
|
|
WithDetail("hint", "We detected an AJAX call, please ensure that CORS is enabled and configured correctly, and that your AJAX code sends cookies and has credentials enabled. For further debugging, check your Browser's Network Tab to see what cookies are included or excluded.")
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenAJAXNoCookies() *herodot.DefaultError {
|
|
return ErrInvalidCSRFTokenAJAX().WithDetail("reject_reason", noCookie)
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenAJAXCookieMissing() *herodot.DefaultError {
|
|
return ErrInvalidCSRFTokenAJAX().WithDetail("reject_reason", cookieMissing)
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenAJAXTokenNotSent() *herodot.DefaultError {
|
|
return ErrInvalidCSRFToken().WithDetail("hint", tokenNotSent)
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenAJAXTokenMismatch() *herodot.DefaultError {
|
|
return ErrInvalidCSRFTokenAJAX().WithDetail("reject_reason", tokenMismatch)
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenServer() *herodot.DefaultError {
|
|
return ErrInvalidCSRFToken().
|
|
WithDetail("hint", "We detected a regular browser or server-side call. To debug browser calls check your Browser's Network Tab to see what cookies are included or excluded. If you are calling from a server ensure that the appropriate cookies are being forwarded and that the SDK method is called correctly.")
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenServerNoCookies() *herodot.DefaultError {
|
|
return ErrInvalidCSRFTokenServer().WithDetail("reject_reason", noCookie)
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenServerCookieMissing() *herodot.DefaultError {
|
|
return ErrInvalidCSRFTokenServer().WithDetail("reject_reason", cookieMissing)
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenServerTokenNotSent() *herodot.DefaultError {
|
|
return ErrInvalidCSRFToken().WithDetail("hint", tokenNotSent)
|
|
}
|
|
|
|
func ErrInvalidCSRFTokenServerTokenMismatch() *herodot.DefaultError {
|
|
return ErrInvalidCSRFTokenServer().WithDetail("reject_reason", tokenMismatch)
|
|
}
|
|
|
|
type CSRFTokenGeneratorProvider interface {
|
|
GenerateCSRFToken(r *http.Request) string
|
|
}
|
|
|
|
type CSRFToken func(r *http.Request) string
|
|
|
|
const CSRFTokenName = "csrf_token"
|
|
|
|
func DefaultCSRFToken(r *http.Request) string {
|
|
return nosurf.Token(r)
|
|
}
|
|
|
|
var FakeCSRFToken = base64.StdEncoding.EncodeToString([]byte(randx.MustString(32, randx.AlphaLowerNum)))
|
|
|
|
func FakeCSRFTokenGenerator(r *http.Request) string {
|
|
return FakeCSRFToken
|
|
}
|
|
|
|
func FakeCSRFTokenGeneratorWithToken(token string) func(r *http.Request) string {
|
|
return func(r *http.Request) string {
|
|
return token
|
|
}
|
|
}
|
|
|
|
var _ nosurf.Handler = new(FakeCSRFHandler)
|
|
|
|
type FakeCSRFHandler struct {
|
|
cookieNameFn func(r *http.Request) string
|
|
}
|
|
|
|
func NewFakeCSRFHandler(reg interface {
|
|
config.Provider
|
|
},
|
|
) *FakeCSRFHandler {
|
|
return &FakeCSRFHandler{
|
|
cookieNameFn: func(r *http.Request) string {
|
|
return CSRFCookieName(r.Context(), reg)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (f *FakeCSRFHandler) DisablePath(s string) {
|
|
}
|
|
|
|
func (f *FakeCSRFHandler) DisableGlob(s string) {
|
|
}
|
|
|
|
func (f *FakeCSRFHandler) DisableGlobs(s ...string) {
|
|
}
|
|
|
|
func (f *FakeCSRFHandler) ExemptPath(s string) {
|
|
}
|
|
|
|
func (f *FakeCSRFHandler) IgnorePath(s string) {
|
|
}
|
|
|
|
func (f *FakeCSRFHandler) IgnoreGlob(s string) {}
|
|
|
|
func (f *FakeCSRFHandler) IgnoreGlobs(s ...string) {}
|
|
|
|
func (f *FakeCSRFHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {}
|
|
|
|
func (f *FakeCSRFHandler) RegenerateToken(_ http.ResponseWriter, _ *http.Request) string {
|
|
return FakeCSRFToken
|
|
}
|
|
|
|
func WithFakeCSRFCookie(t *testing.T, reg interface {
|
|
config.Provider
|
|
}, urlStr string,
|
|
) (*url.URL, []*http.Cookie) {
|
|
u, err := url.Parse(urlStr)
|
|
require.NoError(t, err)
|
|
|
|
cookie := &http.Cookie{
|
|
Name: CSRFCookieName(t.Context(), reg),
|
|
Value: FakeCSRFToken,
|
|
}
|
|
return u, []*http.Cookie{cookie}
|
|
}
|
|
|
|
type CSRFProvider interface {
|
|
CSRFHandler() nosurf.Handler
|
|
}
|
|
|
|
func CSRFCookieName(ctx context.Context, reg interface {
|
|
config.Provider
|
|
},
|
|
) string {
|
|
return "csrf_token_" + fmt.Sprintf("%x", sha256.Sum256([]byte(reg.Config().SelfPublicURL(ctx).String())))
|
|
}
|
|
|
|
func NosurfBaseCookieHandler(reg interface {
|
|
config.Provider
|
|
},
|
|
) func(w http.ResponseWriter, r *http.Request) http.Cookie {
|
|
return func(w http.ResponseWriter, r *http.Request) http.Cookie {
|
|
secure := reg.Config().CookieSecure(r.Context())
|
|
|
|
sameSite := reg.Config().CookieSameSiteMode(r.Context())
|
|
if !secure {
|
|
sameSite = http.SameSiteLaxMode
|
|
}
|
|
|
|
domain := ""
|
|
if d := reg.Config().CookieDomain(r.Context()); d != "" {
|
|
domain = d
|
|
}
|
|
|
|
name := CSRFCookieName(r.Context(), reg)
|
|
cookie := http.Cookie{
|
|
Name: name,
|
|
MaxAge: nosurf.MaxAge,
|
|
Path: reg.Config().CookiePath(r.Context()),
|
|
Domain: domain,
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: sameSite,
|
|
}
|
|
|
|
if alias := reg.Config().SelfPublicURL(r.Context()); reg.Config().SelfPublicURL(r.Context()).String() != alias.String() {
|
|
// If a domain alias is detected use that instead.
|
|
cookie.Domain = alias.Hostname()
|
|
cookie.Path = alias.Path
|
|
}
|
|
|
|
return cookie
|
|
}
|
|
}
|
|
|
|
func CSRFErrorReason(r *http.Request, reg interface {
|
|
config.Provider
|
|
},
|
|
) error {
|
|
// Is it an AJAX request?
|
|
secFetchMode := r.Header.Get("Sec-Fetch-Mode")
|
|
isAjax := secFetchMode == "cors" || secFetchMode == "same-origin"
|
|
|
|
if len(r.Header.Get("Cookie")) == 0 {
|
|
if isAjax {
|
|
return errors.WithStack(ErrInvalidCSRFTokenAJAXNoCookies())
|
|
}
|
|
return errors.WithStack(ErrInvalidCSRFTokenServerNoCookies())
|
|
} else if _, err := r.Cookie(CSRFCookieName(r.Context(), reg)); errors.Is(err, http.ErrNoCookie) {
|
|
if isAjax {
|
|
return errors.WithStack(ErrInvalidCSRFTokenAJAXCookieMissing())
|
|
}
|
|
return errors.WithStack(ErrInvalidCSRFTokenServerCookieMissing())
|
|
}
|
|
|
|
// We can't parse the request body here, so we check if the content type is JSON,
|
|
// as we can't return a meaningful error in that case.
|
|
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
if len(r.Form.Get("csrf_token")+r.Header.Get(nosurf.HeaderName)) == 0 {
|
|
if isAjax {
|
|
return errors.WithStack(ErrInvalidCSRFTokenAJAXTokenNotSent())
|
|
}
|
|
return errors.WithStack(ErrInvalidCSRFTokenServerTokenNotSent())
|
|
}
|
|
}
|
|
|
|
if isAjax {
|
|
return errors.WithStack(ErrInvalidCSRFTokenAJAXTokenMismatch())
|
|
}
|
|
return errors.WithStack(ErrInvalidCSRFTokenServerTokenMismatch())
|
|
}
|
|
|
|
func CSRFFailureHandler(reg interface {
|
|
config.Provider
|
|
logrusx.Provider
|
|
httpx.WriterProvider
|
|
},
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
err := CSRFErrorReason(r, reg)
|
|
reg.Logger().
|
|
WithError(err).
|
|
WithField("result", nosurf.VerifyToken(nosurf.Token(r), r.Form.Get("csrf_token"))).
|
|
WithField("expected_token", nosurf.Token(r)).
|
|
WithField("received_cookies", r.Cookies()).
|
|
WithField("received_token_form", r.Form.Get(nosurf.FormFieldName)).
|
|
WithField("received_token_body", r.PostForm.Get(nosurf.FormFieldName)).
|
|
WithField("received_token_header", r.Header.Get(nosurf.HeaderName)).
|
|
Warn("A request failed due to a missing or invalid csrf_token value")
|
|
|
|
reg.Writer().WriteError(w, r, err)
|
|
}
|
|
}
|
|
|
|
func NewCSRFHandler(
|
|
router http.Handler,
|
|
reg interface {
|
|
config.Provider
|
|
logrusx.Provider
|
|
httpx.WriterProvider
|
|
},
|
|
) *nosurf.CSRFHandler {
|
|
n := nosurf.New(router)
|
|
|
|
n.SetBaseCookieFunc(NosurfBaseCookieHandler(reg))
|
|
n.SetFailureHandler(CSRFFailureHandler(reg))
|
|
return n
|
|
}
|
|
|
|
func NewTestCSRFHandler(router http.Handler, reg interface {
|
|
WithCSRFHandler(handler nosurf.Handler)
|
|
WithCSRFTokenGenerator(CSRFToken)
|
|
httpx.WriterProvider
|
|
logrusx.Provider
|
|
config.Provider
|
|
},
|
|
) *nosurf.CSRFHandler {
|
|
n := NewCSRFHandler(router, reg)
|
|
reg.WithCSRFHandler(n)
|
|
reg.WithCSRFTokenGenerator(nosurf.Token)
|
|
return n
|
|
}
|