slimer 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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