solid_objects 0.12.1 → 0.13.0
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/CHANGELOG.md +37 -0
- data/README.md +58 -11
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/architecture.md +3 -2
- data/docs/authorization.md +11 -4
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/realtime.md +22 -14
- data/docs/roadmap.md +24 -6
- data/docs/security.md +7 -7
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
- data/lib/solid_objects/actor.rb +1 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- metadata +55 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "rack/request"
|
|
5
|
+
require "rack/utils"
|
|
6
|
+
|
|
7
|
+
module SolidObjects
|
|
8
|
+
class Web
|
|
9
|
+
# One request. A route handler runs inside an instance of this class, so a
|
|
10
|
+
# handler and the template it renders share the same helpers and the same
|
|
11
|
+
# request.
|
|
12
|
+
class Action
|
|
13
|
+
include Helpers
|
|
14
|
+
|
|
15
|
+
# @rbs @env: Hash[String, untyped]
|
|
16
|
+
# @rbs @route: Route
|
|
17
|
+
# @rbs @captures: Hash[Symbol, String?]
|
|
18
|
+
# @rbs @request: untyped
|
|
19
|
+
# @rbs @layout_rendered: bool
|
|
20
|
+
# @rbs @response_status: Integer
|
|
21
|
+
|
|
22
|
+
attr_reader :env, :route, :response_status
|
|
23
|
+
|
|
24
|
+
# @rbs (env: Hash[String, untyped], route: Route) -> void
|
|
25
|
+
def initialize(env:, route:)
|
|
26
|
+
@env = env
|
|
27
|
+
@route = route
|
|
28
|
+
@captures = route.capture(env["PATH_INFO"].to_s)
|
|
29
|
+
@layout_rendered = false
|
|
30
|
+
@response_status = 200
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Sets the status a rendered page is served with. `halt` and `redirect`
|
|
34
|
+
# stop the handler, so a page that must render its own body and still
|
|
35
|
+
# report a failure needs this instead.
|
|
36
|
+
# @rbs (Integer) -> void
|
|
37
|
+
def status(code)
|
|
38
|
+
@response_status = code
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @rbs () -> untyped
|
|
42
|
+
def request
|
|
43
|
+
@request ||= ::Rack::Request.new(env)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @rbs () -> Hash[untyped, untyped]?
|
|
47
|
+
def session
|
|
48
|
+
env["rack.session"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs (Symbol) -> String?
|
|
52
|
+
def route_params(key)
|
|
53
|
+
@captures[key]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @rbs (String) -> untyped
|
|
57
|
+
def url_params(key)
|
|
58
|
+
request.params[key]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @rbs () -> untyped
|
|
62
|
+
def call
|
|
63
|
+
instance_exec(&route.handler)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The layout is rendered once per request. The flag is raised before the
|
|
67
|
+
# page body runs so a partial the body renders returns its own fragment
|
|
68
|
+
# rather than a second whole page. The layout reads the page it wraps
|
|
69
|
+
# from `locals.fetch(:content)`.
|
|
70
|
+
# @rbs (Symbol, ?Hash[Symbol, untyped]) -> String
|
|
71
|
+
def erb(name, locals = {})
|
|
72
|
+
return evaluate(Web.template(name), locals) if @layout_rendered
|
|
73
|
+
|
|
74
|
+
@layout_rendered = true
|
|
75
|
+
content = evaluate(Web.template(name), locals)
|
|
76
|
+
evaluate(Web.template(:layout), { content: })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @rbs (Integer, ?String) -> void
|
|
80
|
+
def halt(status, body = ::Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error"))
|
|
81
|
+
throw :halt, [ status, { "content-type" => "text/plain" }, [ body ] ]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @rbs (String) -> void
|
|
85
|
+
def redirect(path)
|
|
86
|
+
throw :halt, [ 302, { "location" => path_to(path) }, [] ]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @rbs (untyped) -> void
|
|
90
|
+
def json(payload)
|
|
91
|
+
throw :halt, [
|
|
92
|
+
200,
|
|
93
|
+
{ "content-type" => "application/json", "cache-control" => "private, no-store" },
|
|
94
|
+
[ JSON.generate(payload) ]
|
|
95
|
+
]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# `locals` is a local variable of this method, so a template reads it by
|
|
101
|
+
# name, and every helper is reachable because the template runs against
|
|
102
|
+
# this object.
|
|
103
|
+
# @rbs (untyped, Hash[Symbol, untyped]) -> String
|
|
104
|
+
def evaluate(template, locals)
|
|
105
|
+
template.result(binding)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Web
|
|
5
|
+
# The pages. Each route states the administration policy it needs, and
|
|
6
|
+
# `call` asks that policy before the handler runs, so a page cannot read
|
|
7
|
+
# the actor tables on behalf of an unauthorized request.
|
|
8
|
+
class Application
|
|
9
|
+
extend Router
|
|
10
|
+
|
|
11
|
+
SCRIPT_PLACEHOLDER = "!script-src!"
|
|
12
|
+
CONTENT_SECURITY_POLICY = [
|
|
13
|
+
"default-src 'self'",
|
|
14
|
+
"base-uri 'self'",
|
|
15
|
+
"form-action 'self'",
|
|
16
|
+
"frame-ancestors 'none'",
|
|
17
|
+
"img-src 'self' data:",
|
|
18
|
+
"style-src 'self'",
|
|
19
|
+
"script-src #{SCRIPT_PLACEHOLDER}",
|
|
20
|
+
"connect-src 'self'",
|
|
21
|
+
"object-src 'none'"
|
|
22
|
+
].join("; ").freeze
|
|
23
|
+
|
|
24
|
+
MAILBOX_MEMBERSHIPS = %w[ready claimed].freeze
|
|
25
|
+
RECENT_LIMIT = 10
|
|
26
|
+
CHART_TYPE_LIMIT = 12
|
|
27
|
+
|
|
28
|
+
head "/", policy: { action: "index", resource: "dashboard" } do
|
|
29
|
+
# The cheapest liveness check available: it proves the dashboard can
|
|
30
|
+
# reach the database the actors run on, and returns no body.
|
|
31
|
+
ReadyMessage.count
|
|
32
|
+
""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
get "/", policy: { action: "index", resource: "dashboard" } do
|
|
36
|
+
@statistics = statistics.to_h
|
|
37
|
+
@processes = Process.order(last_heartbeat_at: :desc).limit(RECENT_LIMIT)
|
|
38
|
+
@dead_letters = DeadLetter.order(last_failed_at: :desc, id: :desc).limit(RECENT_LIMIT)
|
|
39
|
+
# The only chart that costs its own query, and the only one the poller
|
|
40
|
+
# cannot refresh, because the other two read what `/stats` already
|
|
41
|
+
# returns. Bounded so a runtime with many actor types draws a readable
|
|
42
|
+
# chart rather than every type it has ever seen.
|
|
43
|
+
@instances_by_type = Instance
|
|
44
|
+
.group(:actor_type)
|
|
45
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
46
|
+
.limit(CHART_TYPE_LIMIT)
|
|
47
|
+
.count
|
|
48
|
+
erb(:dashboard)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
get "/stats", policy: { action: "index", resource: "dashboard" } do
|
|
52
|
+
json(statistics.to_h)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
get "/instances", policy: { action: "index", resource: "instances" } do
|
|
56
|
+
@paginator = paginate(filtered_instances)
|
|
57
|
+
# Suggestions come from the registry rather than a DISTINCT over the
|
|
58
|
+
# instances table, which no adapter can answer from an index. The field
|
|
59
|
+
# stays free text, so an actor type that is no longer registered is
|
|
60
|
+
# still reachable.
|
|
61
|
+
@actor_types = SolidObjects.registry.to_h.keys.sort
|
|
62
|
+
erb(:instances)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
get "/instances/:id", policy: { action: "show", resource: "instances" } do
|
|
66
|
+
@instance = find_instance
|
|
67
|
+
@ready_messages = @instance.messages
|
|
68
|
+
.where(id: ReadyMessage.select(:message_id))
|
|
69
|
+
.order(sequence: :asc)
|
|
70
|
+
.limit(RECENT_LIMIT)
|
|
71
|
+
@claimed_messages = @instance.messages
|
|
72
|
+
.where(id: ClaimedMessage.select(:message_id))
|
|
73
|
+
.order(sequence: :asc)
|
|
74
|
+
.limit(RECENT_LIMIT)
|
|
75
|
+
@recent_messages = @instance.messages.order(sequence: :desc).limit(RECENT_LIMIT)
|
|
76
|
+
@reminders = Reminder.where(instance_id: @instance.id).order(next_run_at: :asc).limit(RECENT_LIMIT)
|
|
77
|
+
@effects = Effect.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
|
|
78
|
+
@broadcasts = Broadcast.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
|
|
79
|
+
@dead_letters = DeadLetter.where(instance_id: @instance.id).order(last_failed_at: :desc).limit(RECENT_LIMIT)
|
|
80
|
+
erb(:instance)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Pausing stops the activation manager from claiming the instance again.
|
|
84
|
+
# A pass already in flight finishes its turn, and a synchronous caller
|
|
85
|
+
# waiting on this instance times out rather than being answered, so this
|
|
86
|
+
# is an operator brake and not a delivery guarantee.
|
|
87
|
+
post "/instances/:id/pause", policy: { action: "pause", resource: "instances" } do
|
|
88
|
+
instance = find_instance
|
|
89
|
+
instance.update!(paused_at: SolidObjects.database_adapter.database_now)
|
|
90
|
+
redirect("/instances/#{instance.id}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
post "/instances/:id/resume", policy: { action: "resume", resource: "instances" } do
|
|
94
|
+
instance = find_instance
|
|
95
|
+
instance.update!(paused_at: nil)
|
|
96
|
+
redirect("/instances/#{instance.id}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
get "/mailbox", policy: { action: "index", resource: "messages" } do
|
|
100
|
+
@membership = filter_value(MAILBOX_MEMBERSHIPS, default: "ready")
|
|
101
|
+
@paginator = paginate(mailbox_messages(@membership))
|
|
102
|
+
erb(:mailbox)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
get "/messages/:id", policy: { action: "show", resource: "messages" } do
|
|
106
|
+
@message = Message.find_by(id: route_params(:id))
|
|
107
|
+
halt(404) unless @message
|
|
108
|
+
erb(:message)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
get "/reminders", policy: { action: "index", resource: "reminders" } do
|
|
112
|
+
@status = filter_value(Statistics::REMINDER_STATUSES)
|
|
113
|
+
relation = Reminder.order(next_run_at: :asc, id: :asc)
|
|
114
|
+
@paginator = paginate(@status ? relation.where(status: @status) : relation)
|
|
115
|
+
erb(:reminders)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
get "/effects", policy: { action: "index", resource: "effects" } do
|
|
119
|
+
@status = filter_value(Statistics::EFFECT_STATUSES)
|
|
120
|
+
relation = Effect.order(id: :desc)
|
|
121
|
+
@paginator = paginate(@status ? relation.where(status: @status) : relation)
|
|
122
|
+
erb(:effects)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
get "/broadcasts", policy: { action: "index", resource: "broadcasts" } do
|
|
126
|
+
@status = filter_value(Statistics::BROADCAST_STATUSES)
|
|
127
|
+
relation = Broadcast.order(id: :desc)
|
|
128
|
+
@paginator = paginate(@status ? relation.where(status: @status) : relation)
|
|
129
|
+
erb(:broadcasts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
get "/dead_letters", policy: { action: "index", resource: "dead_letters" } do
|
|
133
|
+
@paginator = paginate(DeadLetter.order(last_failed_at: :desc, id: :desc))
|
|
134
|
+
erb(:dead_letters)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
get "/dead_letters/:id", policy: { action: "show", resource: "dead_letters" } do
|
|
138
|
+
@dead_letter = DeadLetter.find_by(id: route_params(:id))
|
|
139
|
+
halt(404) unless @dead_letter
|
|
140
|
+
erb(:dead_letter)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A retry re-enters the mailbox, which refuses work the runtime cannot
|
|
144
|
+
# accept: an actor class that no longer exists, a full mailbox, a payload
|
|
145
|
+
# over the cap. The operator who pressed the button is told which,
|
|
146
|
+
# instead of being handed a bare 500 from the Rack handler.
|
|
147
|
+
post "/dead_letters/:id/retry", policy: { action: "retry", resource: "dead_letters" } do
|
|
148
|
+
SolidObjects.dead_letters.retry(route_params(:id).to_i, authorization_context: self)
|
|
149
|
+
redirect("/dead_letters")
|
|
150
|
+
rescue Unauthorized
|
|
151
|
+
raise
|
|
152
|
+
rescue SolidObjects::Error => error
|
|
153
|
+
@dead_letter = DeadLetter.find_by(id: route_params(:id))
|
|
154
|
+
halt(404) unless @dead_letter
|
|
155
|
+
@error = "#{error.class.name.split("::").last}: #{error.message}"
|
|
156
|
+
status(422)
|
|
157
|
+
erb(:dead_letter)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
get "/processes", policy: { action: "index", resource: "processes" } do
|
|
161
|
+
@status = filter_value(Statistics::PROCESS_STATES)
|
|
162
|
+
relation = Process.order(last_heartbeat_at: :desc)
|
|
163
|
+
@paginator = paginate(@status ? relation.where(shutdown_state: @status) : relation)
|
|
164
|
+
# Counted in one grouped query rather than once per row, because this
|
|
165
|
+
# page is read while the runtime is already under load.
|
|
166
|
+
@activated_counts = Instance
|
|
167
|
+
.where(activation_owner_id: @paginator.records.map(&:id))
|
|
168
|
+
.group(:activation_owner_id)
|
|
169
|
+
.count
|
|
170
|
+
erb(:processes)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @rbs (Hash[String, untyped]) -> Array[untyped]
|
|
174
|
+
def call(env)
|
|
175
|
+
route = self.class.match(env["REQUEST_METHOD"].to_s, env["PATH_INFO"].to_s)
|
|
176
|
+
return not_found unless route
|
|
177
|
+
|
|
178
|
+
action = Action.new(env:, route:)
|
|
179
|
+
return forbidden unless authorized?(action)
|
|
180
|
+
|
|
181
|
+
respond(action, catch(:halt) { action.call })
|
|
182
|
+
rescue Unauthorized
|
|
183
|
+
forbidden
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# @rbs (Action, untyped) -> Array[untyped]
|
|
189
|
+
def respond(action, result)
|
|
190
|
+
return result if result.is_a?(Array)
|
|
191
|
+
|
|
192
|
+
[ action.response_status, page_headers(action.env), [ result.to_s ] ]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @rbs (Action) -> bool
|
|
196
|
+
def authorized?(action)
|
|
197
|
+
policy = action.route.policy
|
|
198
|
+
SolidObjects.configuration.authorize_administration.call(
|
|
199
|
+
action: policy.fetch(:action),
|
|
200
|
+
resource: policy.fetch(:resource),
|
|
201
|
+
resource_id: action.route_params(:id),
|
|
202
|
+
authorization_context: action
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @rbs (Hash[String, untyped]) -> Hash[String, String]
|
|
207
|
+
def page_headers(env)
|
|
208
|
+
{
|
|
209
|
+
"content-type" => "text/html; charset=utf-8",
|
|
210
|
+
"cache-control" => "private, no-store",
|
|
211
|
+
"content-security-policy" => content_security_policy(env),
|
|
212
|
+
"x-content-type-options" => "nosniff",
|
|
213
|
+
"referrer-policy" => "same-origin"
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The chart host is named only when one is configured, so a deployment
|
|
218
|
+
# that vendors the library or turns charts off never advertises a third
|
|
219
|
+
# party origin it does not use.
|
|
220
|
+
# @rbs (Hash[String, untyped]) -> String
|
|
221
|
+
def content_security_policy(env)
|
|
222
|
+
sources = [ "'self'", "'nonce-#{env[Web::NONCE_KEY]}'", Web.chart_library_origin ].compact
|
|
223
|
+
CONTENT_SECURITY_POLICY.sub(SCRIPT_PLACEHOLDER, sources.join(" "))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The cascade header lets a host application serve its own 404 for a path
|
|
227
|
+
# below the mount that the dashboard does not define.
|
|
228
|
+
# @rbs () -> Array[untyped]
|
|
229
|
+
def not_found
|
|
230
|
+
[ 404, { "content-type" => "text/plain", "x-cascade" => "pass" }, [ "Not Found" ] ]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @rbs () -> Array[untyped]
|
|
234
|
+
def forbidden
|
|
235
|
+
[ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module SolidObjects
|
|
8
|
+
class Web
|
|
9
|
+
# A state changing request must carry the token of the session that asked
|
|
10
|
+
# for the form. The token a form receives is masked with a fresh one-time
|
|
11
|
+
# pad on every request, so the bytes on the wire differ each time and a
|
|
12
|
+
# compression side channel cannot recover the session token.
|
|
13
|
+
class CsrfProtection
|
|
14
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
15
|
+
TOKEN_BYTES = 32
|
|
16
|
+
|
|
17
|
+
MISSING_SESSION = <<~MESSAGE
|
|
18
|
+
SolidObjects::Web needs a Rack session for CSRF protection.
|
|
19
|
+
|
|
20
|
+
Mount it inside the application routes so the Rails session middleware runs first:
|
|
21
|
+
|
|
22
|
+
Rails.application.routes.draw do
|
|
23
|
+
mount SolidObjects::Web => "/solid_objects"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
In a bare Rack application, run a session middleware before it:
|
|
27
|
+
|
|
28
|
+
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
|
|
29
|
+
run SolidObjects::Web
|
|
30
|
+
MESSAGE
|
|
31
|
+
|
|
32
|
+
# @rbs (untyped) -> void
|
|
33
|
+
def initialize(app)
|
|
34
|
+
@app = app
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs (Hash[String, untyped]) -> Array[untyped]
|
|
38
|
+
def call(env)
|
|
39
|
+
return forbidden unless accept?(env)
|
|
40
|
+
|
|
41
|
+
session = session!(env)
|
|
42
|
+
session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES)
|
|
43
|
+
env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf])
|
|
44
|
+
@app.call(env)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# @rbs (Hash[String, untyped]) -> bool
|
|
50
|
+
def accept?(env)
|
|
51
|
+
return true if SAFE_METHODS.include?(env["REQUEST_METHOD"])
|
|
52
|
+
|
|
53
|
+
valid?(env, ::Rack::Request.new(env).params["authenticity_token"])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @rbs (Hash[String, untyped], String?) -> bool
|
|
57
|
+
def valid?(env, given)
|
|
58
|
+
return false if given.nil? || given.empty?
|
|
59
|
+
|
|
60
|
+
session = session!(env)
|
|
61
|
+
stored = session[:csrf]
|
|
62
|
+
return false if stored.nil?
|
|
63
|
+
|
|
64
|
+
token = decode(given)
|
|
65
|
+
return false unless token
|
|
66
|
+
|
|
67
|
+
# The secret is not rotated here. A page renders one Retry form per
|
|
68
|
+
# dead letter, and a browser keeps pages open in other tabs, so
|
|
69
|
+
# spending the secret on the first submission would answer 403 to
|
|
70
|
+
# every other form already rendered. Single use is not what a CSRF
|
|
71
|
+
# token provides: it proves the request came from a page this session
|
|
72
|
+
# was served, and the per-request mask below is what keeps the value
|
|
73
|
+
# on the wire from repeating.
|
|
74
|
+
matches?(token, stored)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @rbs (String, String) -> bool
|
|
78
|
+
def matches?(token, stored)
|
|
79
|
+
candidate = case token.bytesize
|
|
80
|
+
when TOKEN_BYTES then token
|
|
81
|
+
when TOKEN_BYTES * 2 then unmask(token)
|
|
82
|
+
else return false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
::Rack::Utils.secure_compare(candidate, decode(stored).to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @rbs (String) -> String
|
|
89
|
+
def mask(token)
|
|
90
|
+
decoded = decode(token).to_s
|
|
91
|
+
pad = SecureRandom.random_bytes(decoded.bytesize)
|
|
92
|
+
encode(pad + exclusive_or(pad, decoded))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @rbs (String) -> String
|
|
96
|
+
def unmask(masked)
|
|
97
|
+
half = masked.bytesize / 2
|
|
98
|
+
exclusive_or(masked[0, half].to_s, masked[half..].to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @rbs (String, String) -> String
|
|
102
|
+
def exclusive_or(left, right)
|
|
103
|
+
left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs (String) -> String
|
|
107
|
+
def encode(token)
|
|
108
|
+
[ token ].pack("m0").tr("+/", "-_")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @rbs (String) -> String?
|
|
112
|
+
def decode(token)
|
|
113
|
+
decoded = token.tr("-_", "+/").unpack1("m0")
|
|
114
|
+
decoded.is_a?(String) ? decoded : nil
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @rbs (Hash[String, untyped]) -> Hash[untyped, untyped]
|
|
120
|
+
def session!(env)
|
|
121
|
+
env["rack.session"] || raise(MISSING_SESSION)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @rbs () -> Array[untyped]
|
|
125
|
+
def forbidden
|
|
126
|
+
[ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|