tynn 1.4.0 → 2.0.0.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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