slimer 0.1.1

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.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slimer
4
+ # Everything needed for handling an HTTP request
5
+ # Borrowed from Sidekiq:
6
+ # https://github.com/mperham/sidekiq/blob/master/lib/sidekiq/web/action.rb
7
+ class WebAction
8
+ RACK_SESSION = "rack.session"
9
+
10
+ attr_accessor :env, :block, :type
11
+
12
+ def settings
13
+ Web.settings
14
+ end
15
+
16
+ def request
17
+ @request ||= ::Rack::Request.new(env)
18
+ end
19
+
20
+ def halt(res)
21
+ throw :halt, res
22
+ end
23
+
24
+ def redirect(location)
25
+ throw :halt, [302, { "Location" => "#{request.base_url}#{location}" }, []]
26
+ end
27
+
28
+ def forbidden
29
+ throw :halt, [403, { "Content-Type" => "text/plain" }, ["Forbidden"]]
30
+ end
31
+
32
+ def authorized?
33
+ api_key = route_params.delete(:api_key)
34
+ return false unless api_key
35
+
36
+ # Ensure a connection to the DB has been made and models loaded
37
+ Slimer.db
38
+ Slimer::ApiKey.where(token: api_key).count.positive?
39
+ end
40
+
41
+ def params
42
+ indifferent_hash = Hash.new { |hash, key| hash[key.to_s] if Symbol === key } # rubocop:disable Style/CaseEquality
43
+
44
+ indifferent_hash.merge! request.params
45
+ route_params.each { |k, v| indifferent_hash[k.to_s] = v }
46
+
47
+ indifferent_hash
48
+ end
49
+
50
+ def route_params
51
+ env[WebRouter::ROUTE_PARAMS]
52
+ end
53
+
54
+ def session
55
+ env[RACK_SESSION]
56
+ end
57
+
58
+ def json(payload)
59
+ [200, { "Content-Type" => "application/json", "Cache-Control" => "no-cache" }, [JSON.generate(payload)]]
60
+ end
61
+
62
+ def initialize(env, block)
63
+ @env = env
64
+ @block = block
65
+ @files ||= {}
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slimer
4
+ # Defines routes and responses for the Slimer web app.
5
+ class WebApplication
6
+ extend WebRouter
7
+
8
+ CONTENT_LENGTH = "Content-Length"
9
+ REDIS_KEYS = %w[redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human].freeze
10
+ CSP_HEADER = [
11
+ "default-src 'self' https: http:",
12
+ "child-src 'self'",
13
+ "connect-src 'self' https: http: wss: ws:",
14
+ "font-src 'self' https: http:",
15
+ "frame-src 'self'",
16
+ "img-src 'self' https: http: data:",
17
+ "manifest-src 'self'",
18
+ "media-src 'self'",
19
+ "object-src 'none'",
20
+ "script-src 'self' https: http: 'unsafe-inline'",
21
+ "style-src 'self' https: http: 'unsafe-inline'",
22
+ "worker-src 'self'",
23
+ "base-uri 'self'"
24
+ ].join("; ").freeze
25
+
26
+ def initialize(klass)
27
+ @klass = klass
28
+ end
29
+
30
+ def settings
31
+ @klass.settings
32
+ end
33
+
34
+ def self.settings
35
+ Slimer::Web.settings
36
+ end
37
+
38
+ def root_path
39
+ "#{env["SCRIPT_NAME"]}/"
40
+ end
41
+
42
+ get "/" do
43
+ json(:ok)
44
+ end
45
+
46
+ get "/status" do
47
+ json(:ok)
48
+ end
49
+
50
+ get "/:api_key/consume" do
51
+ forbidden unless authorized?
52
+
53
+ Slimer::Substance.consume(params)
54
+
55
+ json(:ok)
56
+ end
57
+
58
+ post "/:api_key/consume" do
59
+ forbidden unless authorized?
60
+
61
+ Slimer::Substance.consume(params)
62
+
63
+ json(:ok)
64
+ end
65
+
66
+ get "/:api_key/:group/consume" do
67
+ forbidden unless authorized?
68
+
69
+ exclude_keys = ["group"]
70
+ substance_params = params.select { |k, _v| !exclude_keys.include?(k.to_s) } # rubocop:disable Style/InverseMethods
71
+ Slimer::Substance.consume(substance_params, group: params["group"])
72
+
73
+ json(:ok)
74
+ end
75
+
76
+ post "/:api_key/:group/consume" do
77
+ forbidden unless authorized?
78
+
79
+ exclude_keys = ["group"]
80
+ substance_params = params.select { |k, _v| !exclude_keys.include?(k.to_s) } # rubocop:disable Style/InverseMethods
81
+ Slimer::Substance.consume(substance_params, group: params["group"])
82
+
83
+ json(:ok)
84
+ end
85
+
86
+ def call(env)
87
+ action = self.class.match(env)
88
+ return [404, { "Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not Found"]] unless action
89
+
90
+ resp = catch(:halt) do
91
+ action.instance_exec env, &action.block
92
+ end
93
+
94
+ resolve_response(resp)
95
+ end
96
+
97
+ def resolve_response(resp)
98
+ return resp if resp.is_a?(Array)
99
+
100
+ # rendered content goes here
101
+ headers = {
102
+ "Content-Type" => "text/html",
103
+ "Cache-Control" => "no-cache",
104
+ "Content-Language" => "en",
105
+ "Content-Security-Policy" => CSP_HEADER
106
+ }
107
+ # we'll let Rack calculate Content-Length for us.
108
+ [200, headers, [resp]]
109
+ end
110
+
111
+ def self.helpers(mod = nil, &block)
112
+ if block
113
+ WebAction.class_eval(&block)
114
+ else
115
+ WebAction.send(:include, mod)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ # this file originally based on authenticity_token.rb from the sinatra/rack-protection project
4
+ #
5
+ # The MIT License (MIT)
6
+ #
7
+ # Copyright (c) 2011-2017 Konstantin Haase
8
+ # Copyright (c) 2015-2017 Zachary Scott
9
+ #
10
+ # Permission is hereby granted, free of charge, to any person obtaining
11
+ # a copy of this software and associated documentation files (the
12
+ # 'Software'), to deal in the Software without restriction, including
13
+ # without limitation the rights to use, copy, modify, merge, publish,
14
+ # distribute, sublicense, and/or sell copies of the Software, and to
15
+ # permit persons to whom the Software is furnished to do so, subject to
16
+ # the following conditions:
17
+ #
18
+ # The above copyright notice and this permission notice shall be
19
+ # included in all copies or substantial portions of the Software.
20
+ #
21
+ # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
22
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
23
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
24
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
25
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
26
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
27
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28
+
29
+ require "securerandom"
30
+ require "base64"
31
+ require "rack/request"
32
+
33
+ module Slimer
34
+ class Web
35
+ class CsrfProtection
36
+ def initialize(app, options = nil)
37
+ @app = app
38
+ end
39
+
40
+ def call(env)
41
+ accept?(env) ? admit(env) : deny(env)
42
+ end
43
+
44
+ private
45
+
46
+ def admit(env)
47
+ # On each successful request, we create a fresh masked token
48
+ # which will be used in any forms rendered for this request.
49
+ s = session(env)
50
+ s[:csrf] ||= SecureRandom.base64(TOKEN_LENGTH)
51
+ env[:csrf_token] = mask_token(s[:csrf])
52
+ @app.call(env)
53
+ end
54
+
55
+ def safe?(env)
56
+ %w[GET HEAD OPTIONS TRACE].include? env["REQUEST_METHOD"]
57
+ end
58
+
59
+ def logger(env)
60
+ @logger ||= (env["rack.logger"] || ::Logger.new(env["rack.errors"]))
61
+ end
62
+
63
+ def deny(env)
64
+ logger(env).warn "attack prevented by #{self.class}"
65
+ [403, {"Content-Type" => "text/plain"}, ["Forbidden"]]
66
+ end
67
+
68
+ def session(env)
69
+ env["rack.session"] || fail("you need to set up a session middleware *before* #{self.class}")
70
+ end
71
+
72
+ def accept?(env)
73
+ return true if safe?(env)
74
+
75
+ giventoken = ::Rack::Request.new(env).params["authenticity_token"]
76
+ valid_token?(env, giventoken)
77
+ end
78
+
79
+ TOKEN_LENGTH = 32
80
+
81
+ # Checks that the token given to us as a parameter matches
82
+ # the token stored in the session.
83
+ def valid_token?(env, giventoken)
84
+ return false if giventoken.nil? || giventoken.empty?
85
+
86
+ begin
87
+ token = decode_token(giventoken)
88
+ rescue ArgumentError # client input is invalid
89
+ return false
90
+ end
91
+
92
+ sess = session(env)
93
+ localtoken = sess[:csrf]
94
+
95
+ # Checks that Rack::Session::Cookie actualy contains the csrf toekn
96
+ return false if localtoken.nil?
97
+
98
+ # Rotate the session token after every use
99
+ sess[:csrf] = SecureRandom.base64(TOKEN_LENGTH)
100
+
101
+ # See if it's actually a masked token or not. We should be able
102
+ # to handle any unmasked tokens that we've issued without error.
103
+
104
+ if unmasked_token?(token)
105
+ compare_with_real_token token, localtoken
106
+ elsif masked_token?(token)
107
+ unmasked = unmask_token(token)
108
+ compare_with_real_token unmasked, localtoken
109
+ else
110
+ false # Token is malformed
111
+ end
112
+ end
113
+
114
+ # Creates a masked version of the authenticity token that varies
115
+ # on each request. The masking is used to mitigate SSL attacks
116
+ # like BREACH.
117
+ def mask_token(token)
118
+ token = decode_token(token)
119
+ one_time_pad = SecureRandom.random_bytes(token.length)
120
+ encrypted_token = xor_byte_strings(one_time_pad, token)
121
+ masked_token = one_time_pad + encrypted_token
122
+ Base64.strict_encode64(masked_token)
123
+ end
124
+
125
+ # Essentially the inverse of +mask_token+.
126
+ def unmask_token(masked_token)
127
+ # Split the token into the one-time pad and the encrypted
128
+ # value and decrypt it
129
+ token_length = masked_token.length / 2
130
+ one_time_pad = masked_token[0...token_length]
131
+ encrypted_token = masked_token[token_length..-1]
132
+ xor_byte_strings(one_time_pad, encrypted_token)
133
+ end
134
+
135
+ def unmasked_token?(token)
136
+ token.length == TOKEN_LENGTH
137
+ end
138
+
139
+ def masked_token?(token)
140
+ token.length == TOKEN_LENGTH * 2
141
+ end
142
+
143
+ def compare_with_real_token(token, local)
144
+ ::Rack::Utils.secure_compare(token.to_s, decode_token(local).to_s)
145
+ end
146
+
147
+ def decode_token(token)
148
+ Base64.strict_decode64(token)
149
+ end
150
+
151
+ def xor_byte_strings(s1, s2)
152
+ s1.bytes.zip(s2.bytes).map { |(c1, c2)| c1 ^ c2 }.pack("c*")
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slimer
4
+ # @nodoc
5
+ module WebHelpers
6
+ def root_path
7
+ "#{env["SCRIPT_NAME"]}/"
8
+ end
9
+
10
+ def current_path
11
+ @current_path ||= request.path_info.gsub(%r{^/}, "")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Slimer
6
+ # A simple Rack router.
7
+ # Borrowed from Sidekiq:
8
+ # https://github.com/mperham/sidekiq/blob/master/lib/sidekiq/web/router.rb
9
+ module WebRouter
10
+ GET = "GET"
11
+ DELETE = "DELETE"
12
+ POST = "POST"
13
+ PUT = "PUT"
14
+ PATCH = "PATCH"
15
+ HEAD = "HEAD"
16
+
17
+ ROUTE_PARAMS = "rack.route_params"
18
+ REQUEST_METHOD = "REQUEST_METHOD"
19
+ PATH_INFO = "PATH_INFO"
20
+
21
+ def get(path, &block)
22
+ route(GET, path, &block)
23
+ end
24
+
25
+ def post(path, &block)
26
+ route(POST, path, &block)
27
+ end
28
+
29
+ def put(path, &block)
30
+ route(PUT, path, &block)
31
+ end
32
+
33
+ def patch(path, &block)
34
+ route(PATCH, path, &block)
35
+ end
36
+
37
+ def delete(path, &block)
38
+ route(DELETE, path, &block)
39
+ end
40
+
41
+ def route(method, path, &block)
42
+ @routes ||= { GET => [], POST => [], PUT => [], PATCH => [], DELETE => [], HEAD => [] }
43
+
44
+ @routes[method] << WebRoute.new(method, path, block)
45
+ @routes[HEAD] << WebRoute.new(method, path, block) if method == GET
46
+ end
47
+
48
+ def match(env)
49
+ request_method = env[REQUEST_METHOD]
50
+ path_info = ::Rack::Utils.unescape env[PATH_INFO]
51
+
52
+ # There are servers which send an empty string when requesting the root.
53
+ # These servers should be ashamed of themselves.
54
+ path_info = "/" if path_info == ""
55
+
56
+ @routes[request_method].each do |route|
57
+ params = route.match(request_method, path_info)
58
+ next unless params
59
+
60
+ env[ROUTE_PARAMS] = params
61
+
62
+ return WebAction.new(env, route.block)
63
+ end
64
+
65
+ nil
66
+ end
67
+ end
68
+
69
+ # @abstract A simple Rack request extractor and matcher
70
+ class WebRoute
71
+ attr_accessor :request_method, :pattern, :block, :name
72
+
73
+ NAMED_SEGMENTS_PATTERN = %r{/([^/]*):([^.:$/]+)}.freeze
74
+
75
+ def initialize(request_method, pattern, block)
76
+ @request_method = request_method
77
+ @pattern = pattern
78
+ @block = block
79
+ end
80
+
81
+ def matcher
82
+ @matcher ||= compile
83
+ end
84
+
85
+ def compile
86
+ if pattern.match?(NAMED_SEGMENTS_PATTERN)
87
+ p = pattern.gsub(NAMED_SEGMENTS_PATTERN, '/\1(?<\2>[^$/]+)')
88
+
89
+ Regexp.new("\\A#{p}\\Z")
90
+ else
91
+ pattern
92
+ end
93
+ end
94
+
95
+ def match(_request_method, path)
96
+ case matcher
97
+ when String
98
+ {} if path == matcher
99
+ else
100
+ path_match = path.match(matcher)
101
+ path_match&.named_captures&.transform_keys(&:to_sym)
102
+ end
103
+ end
104
+ end
105
+ end