tynn 1.4.0 → 2.0.0.alpha

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,21 +1,23 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Tynn
2
- # Public: Adds simple cookie based session management. You can pass a secret
4
+ # Adds simple cookie based session management. You can pass a secret
3
5
  # token to sign the cookie data, thus unauthorized means can't alter it.
4
6
  #
5
- # Examples
6
- #
7
7
  # require "tynn"
8
8
  # require "tynn/session"
9
9
  #
10
- # Tynn.plugin(Tynn::Session, secret: "__change_me__")
10
+ # Tynn.plugin(Tynn::Session, secret: "__change_me_not_secure__")
11
11
  #
12
12
  # Tynn.define do
13
- # root do
14
- # res.write(sprintf("hei %s", session[:username]))
15
- # end
13
+ # on("login") do
14
+ # post do
15
+ # # ...
16
+ #
17
+ # session[:user_id] = user.id
16
18
  #
17
- # on(:username) do |username|
18
- # session[:username] = username
19
+ # res.redirect("/admin")
20
+ # end
19
21
  # end
20
22
  # end
21
23
  #
@@ -28,27 +30,27 @@ class Tynn
28
30
  # attacker to tamper the data. So, it's recommended to load the token
29
31
  # from the environment.
30
32
  #
31
- # Examples
32
- #
33
33
  # Tynn.plugin(Tynn::Session, secret: ENV["SESSION_SECRET"])
34
34
  #
35
- # Under the hood, Tynn::Session uses the +Rack::Session::Cookie+ middleware.
36
- # Thus, supports all the options available for this middleware:
35
+ # Under the hood, Tynn::Session uses the <tt>Rack::Session::Cookie</tt>
36
+ # middleware. Thus, supports all the options available for this middleware:
37
37
  #
38
- # key - The name of the cookie. Defaults to <tt>"rack.session"</tt>.
38
+ # [key]
39
+ # The name of the cookie. Defaults to <tt>"rack.session"</tt>.
39
40
  #
40
- # httponly - If +true+, sets the +HttpOnly+ flag. This mitigates the
41
- # risk of client side scripting accessing the cookie. Defaults
42
- # to +true+.
41
+ # [httponly]
42
+ # If <tt>true</tt>, sets the <tt>HttpOnly</tt> flag. This mitigates the
43
+ # risk of client side scripting accessing the cookie. Defaults to <tt>true</tt>.
43
44
  #
44
- # secure - If +true+, sets the +Secure+ flag. This tells the browser
45
- # to only transmit the cookie over HTTPS. Defaults to `false`.
45
+ # [secure]
46
+ # If <tt>true</tt>, sets the <tt>Secure</tt> flag. This tells the browser
47
+ # to only transmit the cookie over HTTPS. Defaults to <tt>false</tt>.
46
48
  #
47
- # expire_after - The lifespan of the cookie. If +nil+, the session cookie
48
- # is temporary and is no retained after the browser is
49
- # closed. Defaults to +nil+.
49
+ # [expire_after]
50
+ # The lifespan of the cookie. If <tt>nil</tt>, the session cookie is temporary
51
+ # and is no retained after the browser is closed. Defaults to <tt>nil</tt>.
50
52
  #
51
- # Examples
53
+ # <tt></tt>
52
54
  #
53
55
  # Tynn.plugin(
54
56
  # Tynn::Session,
@@ -60,27 +62,59 @@ class Tynn
60
62
  # )
61
63
  #
62
64
  module Session
63
- # Internal: Configures Rack::Session::Cookie middleware.
64
- def self.setup(app, options = {})
65
- if app.settings[:ssl]
66
- options = { secure: true }.merge(options)
65
+ SECRET_MIN_LENGTH = 30 # :nodoc:
66
+
67
+ def self.setup(app, options = {}) # :nodoc:
68
+ secret = options[:secret]
69
+
70
+ if secret.nil?
71
+ raise <<~MSG
72
+ No secret option provided to Tynn::Session.
73
+
74
+ Tynn::Session uses a secret token to sign the cookie data, thus
75
+ unauthorized means can't alter it. Please, add the secret option
76
+ to your code:
77
+
78
+ #{ app }.plugin(Tynn::Session, secret: "__a_long_random_secret__", ...)
79
+
80
+ If you're sharing your code publicly, make sure the secret key
81
+ is kept private. Knowing the secret allows an attacker to tamper
82
+ the data. You can use environment variables to store the secret:
83
+
84
+ #{ app }.plugin(Tynn::Session, secret: ENV.fetch("SESSION_SECRET"), ...)
85
+ MSG
86
+ end
87
+
88
+ if secret.length < SECRET_MIN_LENGTH
89
+ raise <<~MSG
90
+ The secret provided is shorter than the minimum length.
91
+
92
+ Make sure the secret is long and all random. You can generate a
93
+ secure secret key with:
94
+
95
+ $ ruby -r securerandom -e "puts SecureRandom.hex(64)"
96
+ MSG
67
97
  end
68
98
 
69
- app.use(Rack::Session::Cookie, options)
99
+ app.use(Rack::Session::Cookie, {
100
+ coder: Rack::Session::Cookie::Base64::JSON.new,
101
+ hmac: OpenSSL::Digest::SHA256,
102
+ same_site: :Lax
103
+ }.merge(options))
70
104
  end
71
105
 
72
106
  module InstanceMethods
73
- # Public: Returns the session hash.
74
- #
75
- # Examples
107
+ # Returns the session hash.
76
108
  #
77
- # session # => {}
109
+ # session
110
+ # # => {}
78
111
  #
79
112
  # session[:foo] = "foo"
80
- # session[:foo] # => "foo"
113
+ # session[:foo]
114
+ # # => "foo"
81
115
  #
82
116
  def session
83
- return req.session
117
+ req.session
84
118
  end
85
119
  end
86
120
  end
@@ -1,8 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Tynn
2
- # Public: It provides a settings API for applications. This plugin is
3
- # included by default.
4
- #
5
- # Examples
4
+ # It provides a settings API for applications and plugins.
6
5
  #
7
6
  # require "tynn"
8
7
  #
@@ -13,7 +12,7 @@ class Tynn
13
12
  #
14
13
  # module ClassMethods
15
14
  # def app_name
16
- # return settings[:app_name]
15
+ # settings[:app_name]
17
16
  # end
18
17
  # end
19
18
  # end
@@ -23,55 +22,85 @@ class Tynn
23
22
  # Tynn.app_name
24
23
  # # => "MyApp"
25
24
  #
25
+ # Tynn.set(:app_name, "MyAwesomeApp")
26
+ #
27
+ # Tynn.app_name
28
+ # # => "MyAwesomeApp"
29
+ #
30
+ # # This plugin is included by default.
31
+ #
26
32
  module Settings
27
- # Internal: Returns a deep copy of a Hash.
28
- def self.deepclone(hash)
33
+ def self.deepclone(hash) # :nodoc:
29
34
  default_proc, hash.default_proc = hash.default_proc, nil
30
35
 
31
- return Marshal.load(Marshal.dump(hash))
36
+ Marshal.load(Marshal.dump(hash))
32
37
  ensure
33
38
  hash.default_proc = default_proc
34
39
  end
35
40
 
36
41
  module ClassMethods
37
- # Internal: Copies settings into the subclass.
38
- # If a setting is not found, checks parent's settings.
39
- def inherited(subclass)
42
+ # Copies settings into the subclass. If a setting is not found,
43
+ # checks parent's settings.
44
+ def inherited(subclass) # :nodoc:
40
45
  subclass.settings.replace(Tynn::Settings.deepclone(settings))
41
46
  subclass.settings.default_proc = proc { |h, k| h[k] = settings[k] }
42
47
  end
43
48
 
44
49
  # Returns a Hash with the application settings.
45
50
  #
46
- # Examples
47
- #
48
51
  # Tynn.set(:environment, :development)
49
52
  #
50
53
  # Tynn.settings
51
54
  # # => { :environment => :development }
52
55
  #
53
56
  def settings
54
- return @settings ||= {}
57
+ @settings ||= {}
55
58
  end
56
- end
57
59
 
58
- module InstanceMethods
59
- # Returns a Hash with the application settings.
60
+ # Sets an <tt>option</tt> to the given </tt>value</tt>. If a setting
61
+ # with the <tt>option</tt> key exists and is a hash value, it merges
62
+ # the stored hash with <tt>value</tt>.
60
63
  #
61
- # Examples
64
+ # Tynn.set(:environment, :staging)
62
65
  #
63
- # Tynn.set(:environment, :development)
66
+ # Tynn.settings[:environment]
67
+ # # => :staging
64
68
  #
65
- # Tynn.define do
66
- # get do
67
- # res.write(settings[:environment])
68
- # end
69
- # end
69
+ # Tynn.default_headers
70
+ # # => { "Content-Type" => "text/html" }
70
71
  #
71
- # GET / # => 200 "development"
72
+ # Tynn.set(:default_headers, "X-Frame-Options" => "DENY")
72
73
  #
73
- def settings
74
- return self.class.settings
74
+ # Tynn.default_headers
75
+ # # => { "Content-Type" => "text/html", "X-Frame-Options" => "DENY" }
76
+ #
77
+ def set(option, value)
78
+ v = settings[option]
79
+
80
+ if Hash === v
81
+ set!(option, v.merge(value))
82
+ else
83
+ set!(option, value)
84
+ end
85
+ end
86
+
87
+ # Sets an <tt>option</tt> to the given </tt>value</tt>.
88
+ #
89
+ # Tynn.set!(:environment, :staging)
90
+ #
91
+ # Tynn.settings[:environment]
92
+ # # => :staging
93
+ #
94
+ # Tynn.default_headers
95
+ # # => { "Content-Type" => "text/html" }
96
+ #
97
+ # Tynn.set!(:default_headers, "X-Frame-Options" => "DENY")
98
+ #
99
+ # Tynn.default_headers
100
+ # # => { "X-Frame-Options" => "DENY" }
101
+ #
102
+ def set!(option, value)
103
+ settings[option] = value
75
104
  end
76
105
  end
77
106
  end
@@ -1,14 +1,37 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Tynn
2
- # Public: Enforces secure HTTP requests by:
4
+ # Enforces secure HTTP requests by:
3
5
  #
4
6
  # 1. Redirecting HTTP requests to their HTTPS counterparts.
5
7
  #
6
- # 2. Setting the +Strict-Transport-Security+ header. This ensures the
7
- # browser never visits the http version of a website. This reduces
8
- # the impact of leaking session data through cookies and external
9
- # links, and defends against Man-in-the-middle attacks.
8
+ # 2. Setting the HTTP <tt>Strict-Transport-Security</tt> header (HSTS).
9
+ # This ensures the browser never visits the http version of a website.
10
+ # This reduces the impact of leaking session data through cookies
11
+ # and external links, and defends against Man-in-the-middle attacks.
12
+ #
13
+ # 3. Setting the <tt>secure</tt> flag on cookies. This tells the browser to
14
+ # only transmit them over HTTPS.
15
+ #
16
+ # You can configure HSTS passing a <tt>:hsts</tt> option. The following options
17
+ # are supported:
18
+ #
19
+ # - *:expires* - The time, in seconds, that the browser access the site only
20
+ # by HTTPS. Defaults to 180 days.
10
21
  #
11
- # Examples
22
+ # - *:subdomains* - If this is <tt>true</tt>, the rule applies to all the
23
+ # site's subdomains as well. Defaults to <tt>true</tt>.
24
+ #
25
+ # - *:preload* - A limitation of HSTS is that the initial request remains
26
+ # unprotected if it uses HTTP. The same applies to the first request after
27
+ # the activity period specified by <tt>max-age</tt>. Modern browsers implements
28
+ # a "HSTS preload list", which contains known sites supporting HSTS. If you
29
+ # would like to include your website into the list, set this option to
30
+ # <tt>true</tt> and submit your domain to this form[https://hstspreload.appspot.com/].
31
+ # Supported by Chrome, Firefox, IE11+ and IE Edge.
32
+ #
33
+ # To disable HSTS, you will need to tell the browser to expire it immediately.
34
+ # Setting <tt>hsts: false</tt> is a shortcut for <tt>hsts: { expires: 0 }</tt>.
12
35
  #
13
36
  # require "tynn"
14
37
  # require "tynn/ssl"
@@ -21,95 +44,80 @@ class Tynn
21
44
  # app = Tynn::Test.new
22
45
  # app.get("/", {}, "HTTP_HOST" => "tynn.xyz")
23
46
  #
24
- # app.res.headers["Location"]
25
- # # => "https://tynn.xyz/"
26
- #
27
- # You can configure HSTS with <tt>{ hsts: { ... } }</tt>. It supports the
28
- # following options:
29
- #
30
- # expires - The time, in seconds, that the browser access the site only
31
- # by HTTPS. Defaults to 180 days.
32
- # subdomains - If this is +true+, the rule applies to all the site's
33
- # subdomains as well. Defaults to +true+.
34
- # preload - A limitation of HSTS is that the initial request remains
35
- # unprotected if it uses HTTP. The same applies to the first
36
- # request after the activity period specified by +max-age+.
37
- # Modern browsers implements a "STS preloaded list", which
38
- # contains known sites supporting HSTS. If you would like to
39
- # include your website into the list, set this options to +true+
40
- # and submit your domain to this {form}[https://hstspreload.appspot.com/].
41
- # Supported by Chrome, Firefox, IE11+ and IE Edge.
42
- #
43
- # Examples
47
+ # app.res.status # => 301
48
+ # app.res.location # => "https://tynn.xyz/"
44
49
  #
50
+ # # Using different HSTS options
45
51
  # Tynn.plugin(
46
52
  # Tynn::SSL,
47
53
  # hsts: {
48
54
  # expires: 31_536_000,
49
- # includeSubdomains: true,
55
+ # includeSubdomains: false,
50
56
  # preload: true
51
57
  # }
52
58
  # )
53
59
  #
54
- # app = Tynn::Test.new
55
- # app.get("/", {}, "HTTPS" => "on")
56
- #
57
- # app.res.headers["Strict-Transport-Security"]
58
- # # => "max-age=31536000; includeSubdomains; preload"
59
- #
60
- # To disable HSTS, you will need to tell the browser to expire it
61
- # immediately.
62
- #
63
- # Examples
64
- #
65
- # Tynn.plugin(Tynn::SSL, hsts: { expires: 0 })
60
+ # # Disabling HSTS
61
+ # Tynn.plugin(Tynn::SSL, hsts: false)
66
62
  #
67
- class SSL
63
+ module SSL
68
64
  def self.setup(app, hsts: {}) # :nodoc:
69
- app.use(self, hsts: hsts)
65
+ app.use(Tynn::SSL::Middleware, hsts: hsts)
70
66
  end
71
67
 
72
- def initialize(app, hsts: {}) # :nodoc:
73
- @app = app
74
- @hsts_header = build_hsts_header(hsts)
75
- end
68
+ class Middleware # :nodoc:
69
+ HSTS_MAX_AGE = 15_552_000 # 180 days
70
+
71
+ def initialize(app, hsts: {})
72
+ @app = app
73
+ @hsts_header = build_hsts_header(hsts || { expires: 0 })
74
+ end
76
75
 
77
- def call(env) # :nodoc:
78
- request = Rack::Request.new(env)
76
+ def call(env)
77
+ request = Rack::Request.new(env)
79
78
 
80
- if request.ssl?
81
- response = @app.call(env)
79
+ return redirect_to_https(request) unless request.ssl?
82
80
 
83
- set_hsts_header!(response[1])
81
+ @app.call(env).tap do |_, headers, _|
82
+ set_hsts_header!(headers)
84
83
 
85
- return response
86
- else
87
- return [301, redirect_headers(request), []]
84
+ flag_cookies_as_secure!(headers)
85
+ end
88
86
  end
89
- end
90
87
 
91
- private
88
+ private
92
89
 
93
- def build_hsts_header(options)
94
- header = sprintf("max-age=%i", options.fetch(:expires, 15_552_000))
95
- header << "; includeSubdomains" if options.fetch(:subdomains, true)
96
- header << "; preload" if options[:preload]
90
+ def build_hsts_header(options)
91
+ header = sprintf("max-age=%i", options.fetch(:expires, HSTS_MAX_AGE))
92
+ header << "; includeSubdomains" if options.fetch(:subdomains, true)
93
+ header << "; preload" if options[:preload]
97
94
 
98
- return header
99
- end
95
+ header
96
+ end
100
97
 
101
- def set_hsts_header!(headers)
102
- headers["Strict-Transport-Security".freeze] ||= @hsts_header
103
- end
98
+ def redirect_to_https(request)
99
+ host = request.host
100
+ port = request.port
104
101
 
105
- def redirect_headers(request)
106
- return { "Location" => https_location(request) }
107
- end
102
+ location = "https://" + host
103
+ location << ":#{ port }" if port != 80 && port != 443
104
+ location << request.fullpath
105
+
106
+ [301, { "Location" => location }, []]
107
+ end
108
108
 
109
- HTTPS_LOCATION = "https://%s%s".freeze
109
+ def set_hsts_header!(headers)
110
+ headers["Strict-Transport-Security"] ||= @hsts_header
111
+ end
112
+
113
+ def flag_cookies_as_secure!(headers)
114
+ return unless cookies = headers["Set-Cookie"]
110
115
 
111
- def https_location(request)
112
- return sprintf(HTTPS_LOCATION, request.host, request.fullpath)
116
+ headers["Set-Cookie"] = cookies.split("\n").map do |cookie|
117
+ cookie << "; secure" if cookie !~ /;\s*secure\s*(;|$)/i
118
+ cookie
119
+ end.join("\n")
120
+ end
113
121
  end
114
122
  end
115
123
  end