Skip to content

Commit 9756065

Browse files
committed
Merge pull request #238 from stve/cookies
add cookies configuration
2 parents 9739fc4 + fc8a80b commit 9756065

File tree

10 files changed

+545
-28
lines changed

10 files changed

+545
-28
lines changed

README.md

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The gem will automatically apply several headers that are related to security.
1515
- X-Permitted-Cross-Domain-Policies - [Restrict Adobe Flash Player's access to data](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html)
1616
- Public Key Pinning - Pin certificate fingerprints in the browser to prevent man-in-the-middle attacks due to compromised Certificate Authorities. [Public Key Pinning Specification](https://tools.ietf.org/html/rfc7469)
1717

18-
It can also mark all http cookies with the secure attribute (when configured to do so).
18+
It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes (when configured to do so).
1919

2020
`secure_headers` is a library with a global config, per request overrides, and rack middleware that enables you customize your application settings.
2121

@@ -31,7 +31,13 @@ All `nil` values will fallback to their default values. `SecureHeaders::OPT_OUT`
3131

3232
```ruby
3333
SecureHeaders::Configuration.default do |config|
34-
config.secure_cookies = true # mark all cookies as "secure"
34+
config.cookies = {
35+
secure: true, # mark all cookies as "Secure"
36+
httponly: true, # mark all cookies as "HttpOnly"
37+
samesite: {
38+
strict: true # mark all cookies as SameSite=Strict
39+
}
40+
}
3541
config.hsts = "max-age=#{20.years.to_i}; includeSubdomains; preload"
3642
config.x_frame_options = "DENY"
3743
config.x_content_type_options = "nosniff"
@@ -264,6 +270,57 @@ config.hpkp = {
264270
}
265271
```
266272

273+
### Cookies
274+
275+
SecureHeaders supports `Secure`, `HttpOnly` and [`SameSite`](https://tools.ietf.org/html/draft-west-first-party-cookies-07) cookies. These can be defined in the form of a boolean, or as a Hash for more refined configuration.
276+
277+
__Note__: Regardless of the configuration specified, Secure cookies are only enabled for HTTPS requests.
278+
279+
#### Boolean-based configuration
280+
281+
Boolean-based configuration is intended to globally enable or disable a specific cookie attribute.
282+
283+
```ruby
284+
config.cookies = {
285+
secure: true, # mark all cookies as Secure
286+
httponly: false, # do not mark any cookies as HttpOnly
287+
}
288+
```
289+
290+
#### Hash-based configuration
291+
292+
Hash-based configuration allows for fine-grained control.
293+
294+
```ruby
295+
config.cookies = {
296+
secure: { except: ['_guest'] }, # mark all but the `_guest` cookie as Secure
297+
httponly: { only: ['_rails_session'] }, # only mark the `_rails_session` cookie as HttpOnly
298+
}
299+
```
300+
301+
#### SameSite cookie configuration
302+
303+
SameSite cookies permit either `Strict` or `Lax` enforcement mode options.
304+
305+
```ruby
306+
config.cookies = {
307+
samesite: {
308+
strict: true # mark all cookies as SameSite=Strict
309+
}
310+
}
311+
```
312+
313+
`Strict` and `Lax` enforcement modes can also be specified using a Hash.
314+
315+
```ruby
316+
config.cookies = {
317+
samesite: {
318+
strict: { only: ['_rails_session'] },
319+
lax: { only: ['_guest'] }
320+
}
321+
}
322+
```
323+
267324
### Using with Sinatra
268325

269326
Here's an example using SecureHeaders for Sinatra applications:

lib/secure_headers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "secure_headers/configuration"
2+
require "secure_headers/headers/cookie"
23
require "secure_headers/headers/public_key_pins"
34
require "secure_headers/headers/content_security_policy"
45
require "secure_headers/headers/x_frame_options"

lib/secure_headers/configuration.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ def deep_copy_if_hash(value)
102102

103103
attr_writer :hsts, :x_frame_options, :x_content_type_options,
104104
:x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
105-
:hpkp, :dynamic_csp, :secure_cookies
105+
:hpkp, :dynamic_csp, :cookies
106106

107-
attr_reader :cached_headers, :csp, :dynamic_csp, :secure_cookies
107+
attr_reader :cached_headers, :csp, :dynamic_csp, :cookies
108108

109109
def initialize(&block)
110110
self.hpkp = OPT_OUT
@@ -117,7 +117,7 @@ def initialize(&block)
117117
# Returns a deep-dup'd copy of this configuration.
118118
def dup
119119
copy = self.class.new
120-
copy.secure_cookies = @secure_cookies
120+
copy.cookies = @cookies
121121
copy.csp = self.class.send(:deep_copy_if_hash, @csp)
122122
copy.dynamic_csp = self.class.send(:deep_copy_if_hash, @dynamic_csp)
123123
copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
@@ -172,13 +172,19 @@ def validate_config!
172172
XDownloadOptions.validate_config!(@x_download_options)
173173
XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
174174
PublicKeyPins.validate_config!(@hpkp)
175+
Cookie.validate_config!(@cookies)
176+
end
177+
178+
def secure_cookies=(secure_cookies)
179+
Kernel.warn "#{Kernel.caller.first}: [DEPRECATION] `#secure_cookies=` is deprecated. Please use `#cookies=` to configure secure cookies instead."
180+
@cookies = (@cookies || {}).merge(secure: secure_cookies)
175181
end
176182

177183
protected
178184

179185
def csp=(new_csp)
180186
if self.dynamic_csp
181-
raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= isntead."
187+
raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= instead."
182188
end
183189

184190
@csp = new_csp
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
require 'cgi'
2+
require 'secure_headers/utils/cookies_config'
3+
4+
module SecureHeaders
5+
class CookiesConfigError < StandardError; end
6+
class Cookie
7+
8+
class << self
9+
def validate_config!(config)
10+
CookiesConfig.new(config).validate!
11+
end
12+
end
13+
14+
attr_reader :raw_cookie, :config
15+
16+
def initialize(cookie, config)
17+
@raw_cookie = cookie
18+
@config = config
19+
@attributes = {
20+
httponly: nil,
21+
samesite: nil,
22+
secure: nil,
23+
}
24+
25+
parse(cookie)
26+
end
27+
28+
def to_s
29+
@raw_cookie.dup.tap do |c|
30+
c << "; secure" if secure?
31+
c << "; HttpOnly" if httponly?
32+
c << "; #{samesite_cookie}" if samesite?
33+
end
34+
end
35+
36+
def secure?
37+
flag_cookie?(:secure) && !already_flagged?(:secure)
38+
end
39+
40+
def httponly?
41+
flag_cookie?(:httponly) && !already_flagged?(:httponly)
42+
end
43+
44+
def samesite?
45+
flag_samesite? && !already_flagged?(:samesite)
46+
end
47+
48+
private
49+
50+
def parsed_cookie
51+
@parsed_cookie ||= CGI::Cookie.parse(raw_cookie)
52+
end
53+
54+
def already_flagged?(attribute)
55+
@attributes[attribute]
56+
end
57+
58+
def flag_cookie?(attribute)
59+
case config[attribute]
60+
when TrueClass
61+
true
62+
when Hash
63+
conditionally_flag?(config[attribute])
64+
else
65+
false
66+
end
67+
end
68+
69+
def conditionally_flag?(configuration)
70+
if(Array(configuration[:only]).any? && (Array(configuration[:only]) & parsed_cookie.keys).any?)
71+
true
72+
elsif(Array(configuration[:except]).any? && (Array(configuration[:except]) & parsed_cookie.keys).none?)
73+
true
74+
else
75+
false
76+
end
77+
end
78+
79+
def samesite_cookie
80+
if flag_samesite_lax?
81+
"SameSite=Lax"
82+
elsif flag_samesite_strict?
83+
"SameSite=Strict"
84+
end
85+
end
86+
87+
def flag_samesite?
88+
flag_samesite_lax? || flag_samesite_strict?
89+
end
90+
91+
def flag_samesite_lax?
92+
flag_samesite_enforcement?(:lax)
93+
end
94+
95+
def flag_samesite_strict?
96+
flag_samesite_enforcement?(:strict)
97+
end
98+
99+
def flag_samesite_enforcement?(mode)
100+
return unless config[:samesite]
101+
102+
case config[:samesite][mode]
103+
when Hash
104+
conditionally_flag?(config[:samesite][mode])
105+
when TrueClass
106+
true
107+
else
108+
false
109+
end
110+
end
111+
112+
def parse(cookie)
113+
return unless cookie
114+
115+
cookie.split(/[;,]\s?/).each do |pairs|
116+
name, values = pairs.split('=',2)
117+
name = CGI.unescape(name)
118+
119+
attribute = name.downcase.to_sym
120+
if @attributes.has_key?(attribute)
121+
@attributes[attribute] = values || true
122+
end
123+
end
124+
end
125+
end
126+
end

lib/secure_headers/middleware.rb

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
module SecureHeaders
22
class Middleware
3-
SECURE_COOKIE_REGEXP = /;\s*secure\s*(;|$)/i.freeze
4-
53
def initialize(app)
64
@app = app
75
end
@@ -12,27 +10,43 @@ def call(env)
1210
status, headers, response = @app.call(env)
1311

1412
config = SecureHeaders.config_for(req)
15-
flag_cookies_as_secure!(headers) if config.secure_cookies
13+
flag_cookies!(headers, override_secure(env, config.cookies)) if config.cookies
1614
headers.merge!(SecureHeaders.header_hash_for(req))
1715
[status, headers, response]
1816
end
1917

2018
private
2119

2220
# inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194
23-
def flag_cookies_as_secure!(headers)
21+
def flag_cookies!(headers, config)
2422
if cookies = headers['Set-Cookie']
2523
# Support Rails 2.3 / Rack 1.1 arrays as headers
2624
cookies = cookies.split("\n") unless cookies.is_a?(Array)
2725

2826
headers['Set-Cookie'] = cookies.map do |cookie|
29-
if cookie !~ SECURE_COOKIE_REGEXP
30-
"#{cookie}; secure"
31-
else
32-
cookie
33-
end
27+
SecureHeaders::Cookie.new(cookie, config).to_s
3428
end.join("\n")
3529
end
3630
end
31+
32+
# disable Secure cookies for non-https requests
33+
def override_secure(env, config = {})
34+
if scheme(env) != 'https'
35+
config.merge!(secure: false)
36+
end
37+
38+
config
39+
end
40+
41+
# derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119
42+
def scheme(env)
43+
if env['HTTPS'] == 'on' || env['HTTP_X_SSL_REQUEST'] == 'on'
44+
'https'
45+
elsif env['HTTP_X_FORWARDED_PROTO']
46+
env['HTTP_X_FORWARDED_PROTO'].split(',')[0]
47+
else
48+
env['rack.url_scheme']
49+
end
50+
end
3751
end
3852
end

0 commit comments

Comments
 (0)