Skip to content

Commit

Permalink
Use csrf session (#150)
Browse files Browse the repository at this point in the history
* Add option to use session to store CSRF token

* Add tests

* Add doc

* Fix findings

* Update doc string
  • Loading branch information
treagod authored Feb 5, 2024
1 parent 15797ab commit 2131750
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 10 deletions.
17 changes: 17 additions & 0 deletions docs/docs/development/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,16 @@ Default: `true`

A boolean indicating if the CSRF protection is enabled globally. When set to `true`, handlers will automatically perform a CSRF check to protect unsafe requests (ie. requests whose methods are not `GET`, `HEAD`, `OPTIONS`, or `TRACE`). Regardless of the value of this setting, it is always possible to explicitly enable or disable CSRF protection on a per-handler basis. See [Cross-Site Request Forgery protection](../../security/csrf.md) for more details.

### `session_key`

Default: `"csrftoken"`

The name of the session key to use for the CSRF token. This session key should be different than any other session key created by your application.

:::info
This value is only relevant if `use_session` is set to `true`.
:::

### `trusted_origins`

Default: `[] of String`
Expand All @@ -318,6 +328,13 @@ config.csrf.trusted_origins = [
]
```

### `use_session`

Default: `false`

A boolean indicating whether the CSRF token should be stored inside a session.
If set to `true`, the CSRF token will be stored [in a session](../../handlers-and-http/sessions.md) rather than in a cookie.

## Content-Security-Policy settings

These settings allow configuring how the [`Marten::Middleware::ContentSecurityPolicy`](../../handlers-and-http/reference/middlewares.md#content-security-policy-middleware) middleware behaves and the actual directives of the Content-Security-Policy header that are set by this middleware.
Expand Down
48 changes: 48 additions & 0 deletions spec/marten/conf/global_settings/csrf_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,33 @@ describe Marten::Conf::GlobalSettings::CSRF do
end
end

describe "#session_key" do
it "returns csrftoken by default" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
csrf_conf.session_key.should eq "csrftoken"
end

it "returns the configured value if applicable" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
csrf_conf.session_key = "custom_name"
csrf_conf.session_key.should eq "custom_name"
end
end

describe "#session_key=" do
it "allows to configure the session name from a string" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
csrf_conf.session_key = "custom_name"
csrf_conf.session_key.should eq "custom_name"
end

it "allows to configure the session name from a symbol" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
csrf_conf.session_key = :custom_name
csrf_conf.session_key.should eq "custom_name"
end
end

describe "#trusted_origins" do
it "returns an empty array by default" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
Expand All @@ -174,4 +201,25 @@ describe Marten::Conf::GlobalSettings::CSRF do
csrf_conf.trusted_origins.should eq ["https://*.example.com"]
end
end

describe "#use_session" do
it "returns false by default" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
csrf_conf.use_session.should be_false
end

it "returns the configured value if applicable" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
csrf_conf.use_session = true
csrf_conf.use_session.should be_true
end
end

describe "#use_session=" do
it "allows to store the CSRF token inside a session" do
csrf_conf = Marten::Conf::GlobalSettings::CSRF.new
csrf_conf.use_session = true
csrf_conf.use_session.should be_true
end
end
end
44 changes: 44 additions & 0 deletions spec/marten/handlers/concerns/request_forgery_protection_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe Marten::Handlers::RequestForgeryProtection do
Marten.settings.allowed_hosts = original_allowed_hosts
Marten.settings.csrf.cookie_domain = original_csrf_cookie_domain
Marten.settings.csrf.protection_enabled = original_csrf_protection_enabled
Marten.settings.csrf.use_session = false
Marten.settings.use_x_forwarded_proto = original_use_x_forwarded_proto
end

Expand Down Expand Up @@ -177,6 +178,30 @@ describe Marten::Handlers::RequestForgeryProtection do
response.status.should eq 200
end

it "allows unsafe requests if the csrftoken POST parameter is specified and matches the sessions CSRF token" do
session_store = Marten::HTTP::Session::Store::Cookie.new("sessionkey")
Marten.settings.csrf.use_session = true

token = Marten::Handlers::RequestForgeryProtectionSpec::EXAMPLE_MASKED_SECRET_1

session_store["csrftoken"] = token

raw_request = ::HTTP::Request.new(
method: "POST",
resource: "/test/xyz",
headers: HTTP::Headers{"Host" => "example.com", "Content-Type" => "application/x-www-form-urlencoded"},
body: "foo=bar&csrftoken=#{token}"
)
request = Marten::HTTP::Request.new(raw_request)
request.session = session_store

handler = Marten::Handlers::RequestForgeryProtectionSpec::TestHandler.new(request)
response = handler.process_dispatch

response.content.should eq "OK_POST"
response.status.should eq 200
end

it "allows unsafe requests if the X-CSRF-Token header is specified and matches the CSRF token cookie" do
token = Marten::Handlers::RequestForgeryProtectionSpec::EXAMPLE_MASKED_SECRET_1

Expand Down Expand Up @@ -776,6 +801,25 @@ describe Marten::Handlers::RequestForgeryProtection do
response.cookies["csrftoken"]?.should_not be_nil
end

it "generates a new token if no one was already set and forces it to be persisted inside the current session" do
session_store = Marten::HTTP::Session::Store::Cookie.new("sessionkey")
Marten.settings.csrf.use_session = true

raw_request = ::HTTP::Request.new(
method: "GET",
resource: "/test/xyz",
headers: HTTP::Headers{"Host" => "example.com"}
)
request = Marten::HTTP::Request.new(raw_request)
request.session = session_store

handler = Marten::Handlers::RequestForgeryProtectionSpec::TestHandlerWithTokenAccess.new(request)
response = handler.process_dispatch

response.cookies["csrftoken"]?.should be_nil
request.session["csrftoken"]?.should_not be_nil
end

it "refreshes the masked version of the original token" do
token = Marten::Handlers::RequestForgeryProtectionSpec::EXAMPLE_MASKED_SECRET_1

Expand Down
16 changes: 16 additions & 0 deletions src/marten/conf/global_settings/csrf.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ module Marten
@cookie_secure : Bool = false
@protection_enabled : Bool = true
@exactly_defined_trusted_origins : Array(String)? = nil
@session_key : String = "csrftoken"
@trusted_origins : Array(String) = [] of String
@trusted_origins_hosts : Array(String)? = nil
@trusted_origin_subdomains_per_scheme : Hash(String, Array(String))? = nil
@use_session : Bool = false

# Returns the domain to use when setting the CSRF cookie.
getter cookie_domain
Expand All @@ -38,6 +40,9 @@ module Marten
# Returns a boolean indicating if CSRF protection is enabled globally (defaults to `true`).
getter protection_enabled

# Returns a boolean indicating if the CSRF token is stored inside the session.
getter use_session

# Returns the array of CSRF-trusted origins.
getter trusted_origins

Expand All @@ -59,6 +64,17 @@ module Marten
# Allows to set whether or not CSRF protection is enabled globally.
setter protection_enabled

# Returns the session key to use for the CSRF token (defaults to `"csrftoken"`).
getter session_key

# Allows to set whether or not the CSRF token should be stored inside the session.
setter use_session

# Allows to set session key to use for the CSRF token.
def session_key=(name : String | Symbol)
@session_key = name.to_s
end

# Allows to set the name of the cookie to use for the CSRF token.
def cookie_name=(name : String | Symbol)
@cookie_name = name.to_s
Expand Down
29 changes: 19 additions & 10 deletions src/marten/handlers/concerns/request_forgery_protection.cr
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ module Marten
# TODO: add support for session-based CSRF tokens.

token = begin
request.cookies[Marten.settings.csrf.cookie_name]
if Marten.settings.csrf.use_session
request.session[Marten.settings.csrf.session_key]
else
request.cookies[Marten.settings.csrf.cookie_name]
end
rescue KeyError
return
end
Expand Down Expand Up @@ -141,15 +145,20 @@ module Marten

private def persist_new_csrf_token
return unless csrf_token && csrf_token_update_required
response!.cookies.set(
name: Marten.settings.csrf.cookie_name,
value: csrf_token,
expires: Time.local + Time::Span.new(seconds: Marten.settings.csrf.cookie_max_age),
domain: Marten.settings.csrf.cookie_domain,
secure: Marten.settings.csrf.cookie_secure,
http_only: Marten.settings.csrf.cookie_http_only,
same_site: Marten.settings.csrf.cookie_same_site
)

if Marten.settings.csrf.use_session
request.session[Marten.settings.csrf.session_key] = csrf_token.to_s
else
response!.cookies.set(
name: Marten.settings.csrf.cookie_name,
value: csrf_token,
expires: Time.local + Time::Span.new(seconds: Marten.settings.csrf.cookie_max_age),
domain: Marten.settings.csrf.cookie_domain,
secure: Marten.settings.csrf.cookie_secure,
http_only: Marten.settings.csrf.cookie_http_only,
same_site: Marten.settings.csrf.cookie_same_site
)
end
end

private def protect_from_forgery
Expand Down

0 comments on commit 2131750

Please sign in to comment.