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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +540 -25
- data/lib/tynn.rb +50 -103
- data/lib/tynn/base.rb +97 -0
- data/lib/tynn/default_headers.rb +50 -0
- data/lib/tynn/environment.rb +54 -28
- data/lib/tynn/json.rb +7 -18
- data/lib/tynn/render.rb +16 -12
- data/lib/tynn/request.rb +54 -38
- data/lib/tynn/response.rb +33 -173
- data/lib/tynn/secure_headers.rb +28 -31
- data/lib/tynn/session.rb +68 -34
- data/lib/tynn/settings.rb +56 -27
- data/lib/tynn/ssl.rb +78 -70
- data/lib/tynn/static.rb +12 -21
- data/lib/tynn/test.rb +51 -78
- data/lib/tynn/version.rb +4 -7
- data/test/default_headers_test.rb +21 -9
- data/test/environment_test.rb +57 -16
- data/test/helper.rb +4 -6
- data/test/json_test.rb +48 -10
- data/test/middleware_test.rb +63 -54
- data/test/plugin_test.rb +121 -0
- data/test/render_test.rb +56 -65
- data/test/request_headers_test.rb +33 -0
- data/test/routing_test.rb +111 -0
- data/test/secure_headers_test.rb +29 -17
- data/test/session_test.rb +44 -11
- data/test/settings_test.rb +53 -0
- data/test/ssl_test.rb +107 -35
- data/test/static_test.rb +25 -6
- metadata +33 -38
- data/lib/tynn/all_methods.rb +0 -50
- data/lib/tynn/hmote.rb +0 -34
- data/lib/tynn/not_found.rb +0 -20
- data/lib/tynn/protection.rb +0 -45
- data/test/all_methods_test.rb +0 -16
- data/test/core_test.rb +0 -65
- data/test/hmote_test.rb +0 -78
- data/test/not_found_test.rb +0 -19
- data/test/protection_test.rb +0 -36
data/lib/tynn/session.rb
CHANGED
@@ -1,21 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
class Tynn
|
2
|
-
#
|
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: "
|
10
|
+
# Tynn.plugin(Tynn::Session, secret: "__change_me_not_secure__")
|
11
11
|
#
|
12
12
|
# Tynn.define do
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
13
|
+
# on("login") do
|
14
|
+
# post do
|
15
|
+
# # ...
|
16
|
+
#
|
17
|
+
# session[:user_id] = user.id
|
16
18
|
#
|
17
|
-
#
|
18
|
-
#
|
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
|
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
|
38
|
+
# [key]
|
39
|
+
# The name of the cookie. Defaults to <tt>"rack.session"</tt>.
|
39
40
|
#
|
40
|
-
# httponly
|
41
|
-
#
|
42
|
-
#
|
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
|
45
|
-
#
|
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
|
48
|
-
#
|
49
|
-
#
|
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
|
-
#
|
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
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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,
|
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
|
-
#
|
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]
|
113
|
+
# session[:foo]
|
114
|
+
# # => "foo"
|
81
115
|
#
|
82
116
|
def session
|
83
|
-
|
117
|
+
req.session
|
84
118
|
end
|
85
119
|
end
|
86
120
|
end
|
data/lib/tynn/settings.rb
CHANGED
@@ -1,8 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
class Tynn
|
2
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
|
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
|
-
#
|
38
|
-
#
|
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
|
-
|
57
|
+
@settings ||= {}
|
55
58
|
end
|
56
|
-
end
|
57
59
|
|
58
|
-
|
59
|
-
#
|
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
|
-
#
|
64
|
+
# Tynn.set(:environment, :staging)
|
62
65
|
#
|
63
|
-
# Tynn.
|
66
|
+
# Tynn.settings[:environment]
|
67
|
+
# # => :staging
|
64
68
|
#
|
65
|
-
# Tynn.
|
66
|
-
#
|
67
|
-
# res.write(settings[:environment])
|
68
|
-
# end
|
69
|
-
# end
|
69
|
+
# Tynn.default_headers
|
70
|
+
# # => { "Content-Type" => "text/html" }
|
70
71
|
#
|
71
|
-
#
|
72
|
+
# Tynn.set(:default_headers, "X-Frame-Options" => "DENY")
|
72
73
|
#
|
73
|
-
|
74
|
-
|
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
|
data/lib/tynn/ssl.rb
CHANGED
@@ -1,14 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
class Tynn
|
2
|
-
#
|
4
|
+
# Enforces secure HTTP requests by:
|
3
5
|
#
|
4
6
|
# 1. Redirecting HTTP requests to their HTTPS counterparts.
|
5
7
|
#
|
6
|
-
# 2. Setting the
|
7
|
-
# browser never visits the http version of a website.
|
8
|
-
# the impact of leaking session data through cookies
|
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
|
-
#
|
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.
|
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:
|
55
|
+
# includeSubdomains: false,
|
50
56
|
# preload: true
|
51
57
|
# }
|
52
58
|
# )
|
53
59
|
#
|
54
|
-
#
|
55
|
-
#
|
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
|
-
|
63
|
+
module SSL
|
68
64
|
def self.setup(app, hsts: {}) # :nodoc:
|
69
|
-
app.use(
|
65
|
+
app.use(Tynn::SSL::Middleware, hsts: hsts)
|
70
66
|
end
|
71
67
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
78
|
-
|
76
|
+
def call(env)
|
77
|
+
request = Rack::Request.new(env)
|
79
78
|
|
80
|
-
|
81
|
-
response = @app.call(env)
|
79
|
+
return redirect_to_https(request) unless request.ssl?
|
82
80
|
|
83
|
-
|
81
|
+
@app.call(env).tap do |_, headers, _|
|
82
|
+
set_hsts_header!(headers)
|
84
83
|
|
85
|
-
|
86
|
-
|
87
|
-
return [301, redirect_headers(request), []]
|
84
|
+
flag_cookies_as_secure!(headers)
|
85
|
+
end
|
88
86
|
end
|
89
|
-
end
|
90
87
|
|
91
|
-
|
88
|
+
private
|
92
89
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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
|
-
|
99
|
-
|
95
|
+
header
|
96
|
+
end
|
100
97
|
|
101
|
-
|
102
|
-
|
103
|
-
|
98
|
+
def redirect_to_https(request)
|
99
|
+
host = request.host
|
100
|
+
port = request.port
|
104
101
|
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
-
|
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
|
-
|
112
|
-
|
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
|