masks-server 0.12.0 → 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/app/controllers/concerns/masks/server/scim_endpoint.rb +2 -2
- data/app/controllers/masks/server/account_exports_controller.rb +2 -2
- data/app/controllers/masks/server/addresses_controller.rb +2 -3
- data/app/controllers/masks/server/authenticator_apps_controller.rb +2 -3
- data/app/controllers/masks/server/authorize_controller.rb +2 -2
- data/app/controllers/masks/server/device_authorizations_controller.rb +2 -2
- data/app/controllers/masks/server/device_verifications_controller.rb +2 -2
- data/app/controllers/masks/server/enrollments_controller.rb +4 -4
- data/app/controllers/masks/server/forward_auth_controller.rb +2 -2
- data/app/controllers/masks/server/introspections_controller.rb +2 -2
- data/app/controllers/masks/server/logins_controller.rb +10 -12
- data/app/controllers/masks/server/organization_members_controller.rb +2 -2
- data/app/controllers/masks/server/passwords_controller.rb +2 -3
- data/app/controllers/masks/server/pushed_authorizations_controller.rb +2 -2
- data/app/controllers/masks/server/received_signals_controller.rb +2 -2
- data/app/controllers/masks/server/registrations_controller.rb +2 -2
- data/app/controllers/masks/server/revocations_controller.rb +2 -2
- data/app/controllers/masks/server/saml_controller.rb +2 -2
- data/app/controllers/masks/server/sign_in_approvals_controller.rb +2 -3
- data/app/controllers/masks/server/tokens_controller.rb +2 -2
- data/app/controllers/masks/server/verifications_controller.rb +2 -3
- data/app/graphql/masks/server/manage/types/health_check_type.rb +1 -1
- data/app/ldap/masks/server/ldap/gate.rb +14 -16
- data/app/models/masks/server/app_passwords.rb +7 -13
- data/app/models/masks/server/client_metadata_document.rb +4 -8
- data/app/models/masks/server/confirmation_code.rb +2 -2
- data/app/models/masks/server/declared.rb +3 -1
- data/app/models/masks/server/event.rb +2 -2
- data/app/models/masks/server/health.rb +70 -3
- data/app/models/masks/server/saml_identity/farewells.rb +1 -0
- data/app/models/masks/server/tenant.rb +48 -4
- data/app/models/masks/server/throttle.rb +165 -0
- data/config/locales/de/manage/health.yml +9 -2
- data/config/locales/en/manage/health.yml +9 -2
- data/config/locales/es/manage/health.yml +9 -2
- data/config/locales/fr/manage/health.yml +9 -2
- data/db/migrate/20261031100000_default_key_rotation_to_ninety_days.rb +5 -0
- data/lib/generators/masks/server/install/install_generator.rb +19 -0
- data/lib/masks/server/configuration.rb +14 -8
- data/lib/masks/server/engine.rb +2 -1
- data/lib/masks/server/forwarding.rb +26 -3
- data/lib/masks/server/tenancy/middleware.rb +4 -7
- data/lib/masks/server/tenant_isolation.rb +12 -0
- data/version.txt +1 -1
- metadata +3 -1
|
@@ -10,11 +10,19 @@ module Masks
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
SERVER = %w[jobs worker queue ldap metrics].freeze
|
|
13
|
-
TENANT = %w[mail origin domains keys].freeze
|
|
13
|
+
TENANT = %w[mail origin domains keys proxy].freeze
|
|
14
14
|
NAMES = (SERVER + TENANT).freeze
|
|
15
15
|
|
|
16
16
|
FAILING_KEY = "masks:health:failing".freeze
|
|
17
17
|
ORIGIN_PREFIX = "masks:health:origin:".freeze
|
|
18
|
+
PROXY_KEY = "masks:health:proxy".freeze
|
|
19
|
+
PROXY_ADDRESSES = 5
|
|
20
|
+
PROXY_LOG = {
|
|
21
|
+
"untrusted" => "proxy: forwarded headers arrived from %{address}, which MASKS_TRUSTED_PROXIES does not list, so they were ignored",
|
|
22
|
+
"plain" => "proxy: %{address} forwards requests as plain http; have it send X-Forwarded-Proto: https"
|
|
23
|
+
}.freeze
|
|
24
|
+
ONE_ADDRESS_FLOOR = 20
|
|
25
|
+
LOOPBACK = /\A(?:(?:.+\.)?localhost|127(?:\.\d{1,3}){3}|\[::1\])\z/i
|
|
18
26
|
KEEP = 1.day
|
|
19
27
|
NOTE_EVERY = 10.minutes
|
|
20
28
|
SMTP_TIMEOUT = 5
|
|
@@ -37,7 +45,7 @@ module Masks
|
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
def tenant_results
|
|
40
|
-
[ mail_result, origin_result, domains_result, keys_result ]
|
|
48
|
+
[ mail_result, origin_result, domains_result, keys_result, proxy_result ]
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
def store!(results)
|
|
@@ -203,6 +211,65 @@ module Masks
|
|
|
203
211
|
"#{ORIGIN_PREFIX}#{tenant.id}"
|
|
204
212
|
end
|
|
205
213
|
|
|
214
|
+
def note_proxy!(kind, address)
|
|
215
|
+
return if address.blank? || !PROXY_LOG.key?(kind)
|
|
216
|
+
|
|
217
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
218
|
+
key = "proxy:#{kind}:#{address}"
|
|
219
|
+
last = noted[key]
|
|
220
|
+
|
|
221
|
+
return if last && now - last < NOTE_EVERY
|
|
222
|
+
|
|
223
|
+
noted[key] = now
|
|
224
|
+
::Rails.logger.warn(format(PROXY_LOG.fetch(kind), address: address))
|
|
225
|
+
|
|
226
|
+
held = ::Rails.cache.read(PROXY_KEY) || {}
|
|
227
|
+
seen = (held[kind] ||= {})
|
|
228
|
+
seen[address] = Time.current.utc.iso8601 if seen.key?(address) || seen.size < PROXY_ADDRESSES
|
|
229
|
+
::Rails.cache.write(PROXY_KEY, held, expires_in: KEEP)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def forget_proxy!
|
|
233
|
+
noted.keys.grep(/\Aproxy:/).each { |key| noted.delete(key) }
|
|
234
|
+
::Rails.cache.delete(PROXY_KEY)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def proxy_result
|
|
238
|
+
held = ::Rails.cache.read(PROXY_KEY) || {}
|
|
239
|
+
untrusted = held["untrusted"].to_h.keys.sort
|
|
240
|
+
plain = held["plain"].to_h.keys.sort
|
|
241
|
+
|
|
242
|
+
return warning("proxy", "untrusted", addresses: untrusted) if untrusted.any?
|
|
243
|
+
return warning("proxy", "plain", addresses: plain) if plain.any?
|
|
244
|
+
|
|
245
|
+
address, count = sole_private_address
|
|
246
|
+
|
|
247
|
+
return warning("proxy", "one", address: address, count: count) if address
|
|
248
|
+
|
|
249
|
+
ok("proxy", "quiet")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def sole_private_address
|
|
253
|
+
origin = Current.tenant&.public_origin
|
|
254
|
+
|
|
255
|
+
return if origin.nil? || URI.parse(origin).host.to_s.match?(LOOPBACK)
|
|
256
|
+
|
|
257
|
+
recent = Event.where(created_at: 1.hour.ago..).where.not(ip_address: [ nil, "" ])
|
|
258
|
+
addresses = recent.distinct.limit(2).pluck(:ip_address)
|
|
259
|
+
|
|
260
|
+
return unless addresses.size == 1
|
|
261
|
+
|
|
262
|
+
count = recent.count
|
|
263
|
+
|
|
264
|
+
return if count < ONE_ADDRESS_FLOOR
|
|
265
|
+
|
|
266
|
+
address = IPAddr.new(addresses.first)
|
|
267
|
+
|
|
268
|
+
[ addresses.first, count ] if address.private? || address.loopback? || address.link_local?
|
|
269
|
+
rescue IPAddr::InvalidAddressError, URI::InvalidURIError
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
|
|
206
273
|
def domains_result
|
|
207
274
|
return ok("domains", "none") unless DomainClaim.exists?
|
|
208
275
|
|
|
@@ -221,7 +288,7 @@ module Masks
|
|
|
221
288
|
days = ((Time.current - active.activated_at) / 1.day).floor
|
|
222
289
|
period = Current.tenant.key_rotation_days
|
|
223
290
|
|
|
224
|
-
return
|
|
291
|
+
return warning("keys", "manual", days: days) if period.nil?
|
|
225
292
|
return warning("keys", "stale", days: days, period: period) if days > period
|
|
226
293
|
|
|
227
294
|
ok("keys", "fresh", days: days, period: period)
|
|
@@ -11,6 +11,7 @@ module Masks
|
|
|
11
11
|
held = ::Rails.cache.read(key)
|
|
12
12
|
|
|
13
13
|
return nil unless held.is_a?(Hash) && held["client_id"] == client.client_id
|
|
14
|
+
return nil unless Replay.first?("saml-farewell", request_id, within: client.id, expires_in: WAIT)
|
|
14
15
|
|
|
15
16
|
::Rails.cache.delete(key)
|
|
16
17
|
new(held["steps"], held["finish"])
|
|
@@ -31,6 +31,21 @@ module Masks
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
class Pooled < StandardError
|
|
35
|
+
def initialize(message = nil)
|
|
36
|
+
super(
|
|
37
|
+
message ||
|
|
38
|
+
"this server reaches Postgres through a pooler that hands out a different server " \
|
|
39
|
+
"connection for each transaction. The tenant masks sets on one connection is gone from " \
|
|
40
|
+
"the next, so a query would see no tenant's rows, or another tenant's. Connect masks to " \
|
|
41
|
+
"Postgres directly, or run the pooler in session mode."
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
POOL_PROBE = "masks.pool_probe".freeze
|
|
47
|
+
POOL_PROBE_ROUNDS = 3
|
|
48
|
+
|
|
34
49
|
encrypts :pairwise_salt, :setup_token, key_provider: Server.tenant_key_provider
|
|
35
50
|
|
|
36
51
|
attribute :uuid, default: -> { SecureRandom.uuid }
|
|
@@ -348,10 +363,28 @@ module Masks
|
|
|
348
363
|
|
|
349
364
|
raise Exposed, held&.fetch("rolname", nil) unless held && held["bypasses"] == false
|
|
350
365
|
raise Owner, held["rolname"] if held["owns"] && guards_ownership?
|
|
366
|
+
raise Pooled if pooled?
|
|
351
367
|
|
|
352
368
|
@isolated = true
|
|
353
369
|
end
|
|
354
370
|
|
|
371
|
+
def pooled?
|
|
372
|
+
return false if connection_pool.size < 2
|
|
373
|
+
|
|
374
|
+
POOL_PROBE_ROUNDS.times do
|
|
375
|
+
mine = SecureRandom.hex(8)
|
|
376
|
+
connection.exec_query("SELECT set_config($1, $2, false)", "probe", [ POOL_PROBE, mine ])
|
|
377
|
+
|
|
378
|
+
return false unless touched_another_connection?
|
|
379
|
+
|
|
380
|
+
return true unless connection.select_value("SELECT current_setting('#{POOL_PROBE}', true)") == mine
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
false
|
|
384
|
+
rescue ActiveRecord::ConnectionTimeoutError
|
|
385
|
+
false
|
|
386
|
+
end
|
|
387
|
+
|
|
355
388
|
def role_privileges
|
|
356
389
|
connection.select_one(<<~SQL)
|
|
357
390
|
SELECT rolname, rolsuper OR rolbypassrls AS bypasses,
|
|
@@ -383,23 +416,34 @@ module Masks
|
|
|
383
416
|
end
|
|
384
417
|
|
|
385
418
|
def clear!
|
|
386
|
-
enter(nil)
|
|
419
|
+
enter(nil, for_transaction: false)
|
|
387
420
|
end
|
|
388
421
|
|
|
389
422
|
private
|
|
390
423
|
|
|
391
|
-
def enter(tenant)
|
|
424
|
+
def enter(tenant, for_transaction: connection.transaction_open?)
|
|
392
425
|
Current.tenant = tenant
|
|
393
426
|
|
|
394
427
|
connection.exec_query(
|
|
395
|
-
"SELECT set_config($1, $2,
|
|
396
|
-
[ TenantIsolation::SETTING, tenant&.id.to_s ]
|
|
428
|
+
"SELECT set_config($1, $2, $3)", "tenant",
|
|
429
|
+
[ TenantIsolation::SETTING, tenant&.id.to_s, for_transaction ]
|
|
397
430
|
)
|
|
398
431
|
|
|
399
432
|
connection.clear_query_cache
|
|
400
433
|
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::ConnectionFailed
|
|
401
434
|
nil
|
|
402
435
|
end
|
|
436
|
+
|
|
437
|
+
def touched_another_connection?
|
|
438
|
+
other = connection_pool.checkout
|
|
439
|
+
|
|
440
|
+
return false if other.equal?(connection)
|
|
441
|
+
|
|
442
|
+
other.exec_query("SELECT set_config($1, $2, false)", "probe", [ POOL_PROBE, "another connection" ])
|
|
443
|
+
true
|
|
444
|
+
ensure
|
|
445
|
+
connection_pool.checkin(other) if other
|
|
446
|
+
end
|
|
403
447
|
end
|
|
404
448
|
|
|
405
449
|
def signing_key
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
module Throttle
|
|
4
|
+
class Invalid < StandardError; end
|
|
5
|
+
|
|
6
|
+
Budget = Struct.new(:name, :to, :within, :about, :variable, keyword_init: true) do
|
|
7
|
+
def key
|
|
8
|
+
name.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def limits
|
|
12
|
+
{ to: to, within: within }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def reference
|
|
16
|
+
"| `#{variable}` | `#{key}` | #{to} in #{within.inspect} | #{about} |"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
DEFAULTS = [
|
|
21
|
+
Budget.new(name: :attempt, to: 10, within: 3.minutes, variable: "MASKS_ATTEMPT_LIMIT",
|
|
22
|
+
about: "Sign-in attempts from one IP address. Includes sign-in through a provider, failed LDAP binds, and app passwords."),
|
|
23
|
+
Budget.new(name: :account_attempt, to: 5, within: 3.minutes, variable: "MASKS_ACCOUNT_ATTEMPT_LIMIT",
|
|
24
|
+
about: "Sign-in attempts against one account, from any IP address. Includes failed LDAP binds and app passwords. " \
|
|
25
|
+
"Also how often a signed-in account changes its password, email address, or phone number, adds an " \
|
|
26
|
+
"authenticator app, or approves a sign-in on another device."),
|
|
27
|
+
Budget.new(name: :recovery, to: 5, within: 15.minutes, variable: "MASKS_RECOVERY_LIMIT",
|
|
28
|
+
about: "Codes and links sent by email or text message. Counted by IP address during sign-in, and by account for " \
|
|
29
|
+
"every address or phone number a code goes to, including a phone added or changed during sign-in."),
|
|
30
|
+
Budget.new(name: :registration, to: 10, within: 10.minutes, variable: "MASKS_REGISTRATION_LIMIT",
|
|
31
|
+
about: "Dynamic client registrations from one IP address, and new client metadata documents."),
|
|
32
|
+
Budget.new(name: :device_code, to: 30, within: 3.minutes, variable: "MASKS_DEVICE_CODE_LIMIT",
|
|
33
|
+
about: "Device user codes entered from one IP address."),
|
|
34
|
+
Budget.new(name: :claim, to: 3, within: 1.hour, variable: "MASKS_CLAIM_LIMIT",
|
|
35
|
+
about: "Tenants claimed from one IP address, when a subdomain template claims a tenant on its first visit. " \
|
|
36
|
+
"Counted across the whole server."),
|
|
37
|
+
Budget.new(name: :claim_ceiling, to: 30, within: 1.hour, variable: "MASKS_CLAIM_CEILING",
|
|
38
|
+
about: "Tenants claimed from all IP addresses together, under the same template. Counted across the whole server."),
|
|
39
|
+
Budget.new(name: :token, to: 60, within: 1.minute, variable: "MASKS_TOKEN_LIMIT", about: "Token requests from one IP address."),
|
|
40
|
+
Budget.new(name: :pushed_authorization, to: 60, within: 1.minute, variable: "MASKS_PUSHED_AUTHORIZATION_LIMIT", about: "Pushed authorization requests from one IP address."),
|
|
41
|
+
Budget.new(name: :device_authorization, to: 60, within: 1.minute, variable: "MASKS_DEVICE_AUTHORIZATION_LIMIT", about: "Device authorization requests from one IP address."),
|
|
42
|
+
Budget.new(name: :introspection, to: 120, within: 1.minute, variable: "MASKS_INTROSPECTION_LIMIT", about: "Token introspections from one IP address."),
|
|
43
|
+
Budget.new(name: :revocation, to: 120, within: 1.minute, variable: "MASKS_REVOCATION_LIMIT", about: "Token revocations from one IP address."),
|
|
44
|
+
Budget.new(name: :saml, to: 60, within: 1.minute, variable: "MASKS_SAML_LIMIT", about: "SAML sign-in and logout requests from one IP address."),
|
|
45
|
+
Budget.new(name: :forward_auth, to: 30, within: 1.minute, variable: "MASKS_FORWARD_AUTH_LIMIT", about: "Forward auth sign-ins started or finished from one IP address."),
|
|
46
|
+
Budget.new(name: :enrollment, to: 30, within: 1.minute, variable: "MASKS_ENROLLMENT_LIMIT", about: "Enrollment tokens redeemed from one IP address."),
|
|
47
|
+
Budget.new(name: :app_enrollment, to: 60, within: 1.hour, variable: "MASKS_APP_ENROLLMENT_LIMIT", about: "Enrollment tokens one app mints."),
|
|
48
|
+
Budget.new(name: :invitation, to: 30, within: 1.minute, variable: "MASKS_INVITATION_LIMIT", about: "Organization invitations one account sends or sends again."),
|
|
49
|
+
Budget.new(name: :export, to: 5, within: 1.hour, variable: "MASKS_EXPORT_LIMIT", about: "Account exports one account asks for."),
|
|
50
|
+
Budget.new(name: :access_request, to: 1, within: 1.hour, variable: "MASKS_ACCESS_REQUEST_LIMIT", about: "Times one account asks for access to one app."),
|
|
51
|
+
Budget.new(name: :signal, to: 600, within: 1.minute, variable: "MASKS_SIGNAL_LIMIT", about: "Security events received from one IP address."),
|
|
52
|
+
Budget.new(name: :scim, to: 600, within: 1.minute, variable: "MASKS_SCIM_LIMIT", about: "SCIM provisioning requests from one IP address.")
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
NAMES = DEFAULTS.map(&:key).freeze
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
def budgets
|
|
60
|
+
@budgets ||= DEFAULTS.to_h { |budget| [ budget.name, budget.dup.tap { |held| held.to = limit_for(budget) } ] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def budget(name)
|
|
64
|
+
budgets.fetch(name.to_sym) { raise Invalid, "no limit is called #{name}. The limits are #{NAMES.join(', ')}." }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def limit(name)
|
|
68
|
+
budget(name).to
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def limits(name)
|
|
72
|
+
budget(name).limits
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def refused(name, &block)
|
|
76
|
+
held = budget(name)
|
|
77
|
+
|
|
78
|
+
proc { Throttle.hit!(held.name); instance_exec(&block) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def key(name, by, scope: nil, named: nil)
|
|
82
|
+
held = budget(name)
|
|
83
|
+
|
|
84
|
+
[ "rate-limit", scope || "masks/server/#{held.key}", named, by ].compact.join(":")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def count!(name, by, scope: nil, named: nil)
|
|
88
|
+
::Rails.cache.increment(key(name, by, scope: scope, named: named), 1, expires_in: budget(name).within)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def exceeded?(name, by, scope: nil, named: nil, times: 1)
|
|
92
|
+
::Rails.cache.read(key(name, by, scope: scope, named: named)).to_i >= budget(name).to * times
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def crowded?(name, by, scope: nil, named: nil, times: 1)
|
|
96
|
+
held = budget(name)
|
|
97
|
+
|
|
98
|
+
::Rails.cache.increment(key(name, by, scope: scope, named: named), 1, expires_in: held.within).to_i > held.to * times
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def hit!(name, actor: nil, client: nil, address: Current.ip_address, **details)
|
|
102
|
+
held = budget(name)
|
|
103
|
+
|
|
104
|
+
::Rails.logger.info("masks: the #{held.key} limit of #{held.to} in #{held.within.inspect} was reached" \
|
|
105
|
+
"#{address.present? ? " from #{address}" : ''}")
|
|
106
|
+
|
|
107
|
+
return if Current.tenant.nil?
|
|
108
|
+
return unless ::Rails.cache.write(key(name, "reached:#{address}"), true, unless_exist: true, expires_in: held.within)
|
|
109
|
+
|
|
110
|
+
Event.record!(Event::LIMIT_REACHED, actor: actor, by: nil, client: client, limit: held.key, **details)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def reference
|
|
114
|
+
DEFAULTS.map(&:reference)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reset!
|
|
118
|
+
@budgets = nil
|
|
119
|
+
@declared = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def limit_for(budget)
|
|
123
|
+
from_env(budget) || declared.fetch(budget.key, nil) || budget.to
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def from_env(budget)
|
|
127
|
+
value = ENV[budget.variable]
|
|
128
|
+
|
|
129
|
+
return nil if value.blank?
|
|
130
|
+
|
|
131
|
+
whole!(value, budget.variable)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def declared
|
|
135
|
+
@declared ||= read_declared(::Rails.configuration.masks.config_path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def read_declared(path)
|
|
139
|
+
return {} if path.blank?
|
|
140
|
+
|
|
141
|
+
document = YAML.safe_load_file(path, aliases: false) || {}
|
|
142
|
+
limits = document.is_a?(Hash) ? document.fetch("limits", nil) || {} : {}
|
|
143
|
+
|
|
144
|
+
raise Invalid, "limits in #{path} must map each limit's name to a whole number" unless limits.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
unknown = limits.keys.map(&:to_s) - NAMES
|
|
147
|
+
raise Invalid, "#{path} sets limits called #{unknown.join(', ')}, which do not exist. The limits are #{NAMES.join(', ')}." if unknown.any?
|
|
148
|
+
|
|
149
|
+
limits.to_h { |name, value| [ name.to_s, whole!(value, "limits.#{name} in #{path}") ] }
|
|
150
|
+
rescue Errno::ENOENT, Psych::Exception
|
|
151
|
+
{}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def whole!(value, where)
|
|
155
|
+
count = Integer(value.to_s, 10)
|
|
156
|
+
|
|
157
|
+
raise Invalid, "#{where} is #{value.inspect}. A limit is a whole number, 1 or more." if count < 1
|
|
158
|
+
|
|
159
|
+
count
|
|
160
|
+
rescue ArgumentError, TypeError
|
|
161
|
+
raise Invalid, "#{where} is #{value.inspect}. A limit is a whole number, 1 or more."
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -65,13 +65,20 @@ de:
|
|
|
65
65
|
proven: "Jede beanspruchte Domain hat noch ihren TXT-Eintrag."
|
|
66
66
|
missing: "Der TXT-Eintrag fehlt für %{domains}. Ein Anspruch wird eine Woche nach dem Verschwinden seines Eintrags freigegeben."
|
|
67
67
|
advice: "Legen Sie den TXT-Eintrag wieder unter dem Namen an, der auf der Seite Domains steht."
|
|
68
|
+
proxy:
|
|
69
|
+
title: "Reverse-Proxy"
|
|
70
|
+
quiet: "Weitergeleitete Header kommen nur von vertrauten Proxys, über HTTPS."
|
|
71
|
+
untrusted: "Weitergeleitete Header kamen von %{addresses}, das nicht in MASKS_TRUSTED_PROXIES steht, und wurden deshalb ignoriert. Alle Besucher hinter diesem Proxy teilen sich eine Adresse und ein Kontingent."
|
|
72
|
+
plain: "Der Proxy unter %{addresses} leitet Anfragen als einfaches HTTP weiter. masks markiert seine Cookies nur für HTTPS, also verwerfen Browser sie, und Anmeldungen laufen im Kreis."
|
|
73
|
+
one: "Alle %{count} Ereignisse der letzten Stunde kamen von %{address}, einer privaten Adresse, also höchstwahrscheinlich vom Proxy selbst."
|
|
74
|
+
advice: "Tragen Sie den Proxy in MASKS_TRUSTED_PROXIES ein und lassen Sie ihn X-Forwarded-For und X-Forwarded-Proto senden. Suchen Sie im Serverprotokoll nach Zeilen, die mit proxy: beginnen."
|
|
68
75
|
keys:
|
|
69
76
|
title: "Signaturschlüssel"
|
|
70
77
|
missing: "Dieser Mandant hat keinen aktiven Signaturschlüssel, also kann nichts signiert werden."
|
|
71
|
-
manual: "Schlüssel werden von Hand rotiert
|
|
78
|
+
manual: "Schlüssel werden von Hand rotiert, also ersetzt nichts den aktiven Schlüssel von selbst. Er ist %{days} Tage alt."
|
|
72
79
|
fresh: "Der aktive Schlüssel ist %{days} Tage alt und rotiert alle %{period} Tage."
|
|
73
80
|
stale: "Der aktive Schlüssel ist %{days} Tage alt, älter als seine Rotation von %{period} Tagen."
|
|
74
|
-
advice: "
|
|
81
|
+
advice: "Wählen Sie auf der Seite Einstellungen einen Rotationsplan, rotieren Sie den Schlüssel dort, oder lesen Sie den Job signing_keys im Serverprotokoll."
|
|
75
82
|
release:
|
|
76
83
|
available: "Version %{version} ist verfügbar."
|
|
77
84
|
operator: "Diese Version braucht einen Schritt von einem Betreiber. Lesen Sie ihre Hinweise vor dem Upgrade."
|
|
@@ -65,13 +65,20 @@ en:
|
|
|
65
65
|
proven: "Every claimed domain still has its TXT record."
|
|
66
66
|
missing: "The TXT record is gone for %{domains}. A claim is released a week after its record disappears."
|
|
67
67
|
advice: "Put the TXT record back at the name shown on the Domains page."
|
|
68
|
+
proxy:
|
|
69
|
+
title: "Reverse proxy"
|
|
70
|
+
quiet: "Forwarded headers come only from trusted proxies, over HTTPS."
|
|
71
|
+
untrusted: "Forwarded headers came from %{addresses}, which MASKS_TRUSTED_PROXIES does not list, so they were ignored. Every visitor behind that proxy shares one address and one set of rate limits."
|
|
72
|
+
plain: "The proxy at %{addresses} forwards requests as plain HTTP. masks marks its cookies for HTTPS only, so browsers drop them and sign-ins loop."
|
|
73
|
+
one: "All %{count} events in the last hour came from %{address}, a private address, which is most likely the proxy itself."
|
|
74
|
+
advice: "List the proxy in MASKS_TRUSTED_PROXIES and have it send X-Forwarded-For and X-Forwarded-Proto. Search the server log for lines that start with proxy:."
|
|
68
75
|
keys:
|
|
69
76
|
title: "Signing keys"
|
|
70
77
|
missing: "This tenant has no active signing key, so nothing can be signed."
|
|
71
|
-
manual: "Keys rotate by hand
|
|
78
|
+
manual: "Keys rotate by hand, so nothing replaces the active key on its own. It is %{days} days old."
|
|
72
79
|
fresh: "The active key is %{days} days old and rotates every %{period} days."
|
|
73
80
|
stale: "The active key is %{days} days old, past its %{period} day rotation."
|
|
74
|
-
advice: "
|
|
81
|
+
advice: "Pick a rotation schedule on the Settings page, rotate the key there, or read the signing_keys job in the server log."
|
|
75
82
|
release:
|
|
76
83
|
available: "Version %{version} is available."
|
|
77
84
|
operator: "This release needs a step from an operator. Read its notes before you upgrade."
|
|
@@ -65,13 +65,20 @@ es:
|
|
|
65
65
|
proven: "Cada dominio reclamado conserva su registro TXT."
|
|
66
66
|
missing: "Falta el registro TXT de %{domains}. Una reclamación se libera una semana después de que desaparezca su registro."
|
|
67
67
|
advice: "Vuelve a poner el registro TXT en el nombre que muestra la página Dominios."
|
|
68
|
+
proxy:
|
|
69
|
+
title: "Proxy inverso"
|
|
70
|
+
quiet: "Las cabeceras reenviadas llegan solo desde proxies de confianza, por HTTPS."
|
|
71
|
+
untrusted: "Llegaron cabeceras reenviadas desde %{addresses}, que no figura en MASKS_TRUSTED_PROXIES, así que se ignoraron. Todos los visitantes detrás de ese proxy comparten una dirección y un mismo límite."
|
|
72
|
+
plain: "El proxy en %{addresses} reenvía las peticiones como HTTP sin cifrar. masks marca sus cookies solo para HTTPS, así que los navegadores las descartan y el inicio de sesión se repite en bucle."
|
|
73
|
+
one: "Los %{count} eventos de la última hora llegaron todos desde %{address}, una dirección privada, que casi seguro es el propio proxy."
|
|
74
|
+
advice: "Añade el proxy a MASKS_TRUSTED_PROXIES y haz que envíe X-Forwarded-For y X-Forwarded-Proto. Busca en el registro del servidor las líneas que empiezan por proxy:."
|
|
68
75
|
keys:
|
|
69
76
|
title: "Claves de firma"
|
|
70
77
|
missing: "Este inquilino no tiene ninguna clave de firma activa, así que no se puede firmar nada."
|
|
71
|
-
manual: "Las claves se rotan a mano
|
|
78
|
+
manual: "Las claves se rotan a mano, así que nada sustituye la clave activa por sí solo. Tiene %{days} días."
|
|
72
79
|
fresh: "La clave activa tiene %{days} días y rota cada %{period} días."
|
|
73
80
|
stale: "La clave activa tiene %{days} días, más que su rotación de %{period} días."
|
|
74
|
-
advice: "
|
|
81
|
+
advice: "Elige un calendario de rotación en la página Configuración, rota la clave allí, o lee la tarea signing_keys en el registro del servidor."
|
|
75
82
|
release:
|
|
76
83
|
available: "La versión %{version} está disponible."
|
|
77
84
|
operator: "Esta versión necesita un paso de un operador. Lee sus notas antes de actualizar."
|
|
@@ -65,13 +65,20 @@ fr:
|
|
|
65
65
|
proven: "Chaque domaine revendiqué a toujours son enregistrement TXT."
|
|
66
66
|
missing: "L'enregistrement TXT manque pour %{domains}. Une revendication est libérée une semaine après la disparition de son enregistrement."
|
|
67
67
|
advice: "Remettez l'enregistrement TXT au nom indiqué sur la page Domaines."
|
|
68
|
+
proxy:
|
|
69
|
+
title: "Proxy inverse"
|
|
70
|
+
quiet: "Les en-têtes transmis ne viennent que de proxys de confiance, en HTTPS."
|
|
71
|
+
untrusted: "Des en-têtes transmis sont arrivés de %{addresses}, qui ne figure pas dans MASKS_TRUSTED_PROXIES, et ont donc été ignorés. Tous les visiteurs derrière ce proxy partagent une adresse et une même limite."
|
|
72
|
+
plain: "Le proxy à %{addresses} transmet les requêtes en HTTP non chiffré. masks réserve ses cookies au HTTPS, donc les navigateurs les rejettent et la connexion tourne en boucle."
|
|
73
|
+
one: "Les %{count} événements de la dernière heure viennent tous de %{address}, une adresse privée, qui est presque sûrement le proxy lui-même."
|
|
74
|
+
advice: "Ajoutez le proxy à MASKS_TRUSTED_PROXIES et faites-lui envoyer X-Forwarded-For et X-Forwarded-Proto. Cherchez dans le journal du serveur les lignes qui commencent par proxy:."
|
|
68
75
|
keys:
|
|
69
76
|
title: "Clés de signature"
|
|
70
77
|
missing: "Ce locataire n'a aucune clé de signature active, donc rien ne peut être signé."
|
|
71
|
-
manual: "Les clés tournent à la main
|
|
78
|
+
manual: "Les clés tournent à la main, donc rien ne remplace la clé active de lui-même. Elle a %{days} jours."
|
|
72
79
|
fresh: "La clé active a %{days} jours et tourne tous les %{period} jours."
|
|
73
80
|
stale: "La clé active a %{days} jours, au-delà de sa rotation de %{period} jours."
|
|
74
|
-
advice: "
|
|
81
|
+
advice: "Choisissez un calendrier de rotation sur la page Paramètres, faites tourner la clé là, ou lisez la tâche signing_keys dans le journal du serveur."
|
|
75
82
|
release:
|
|
76
83
|
available: "La version %{version} est disponible."
|
|
77
84
|
operator: "Cette version demande une étape d'un opérateur. Lisez ses notes avant de mettre à jour."
|
|
@@ -39,6 +39,7 @@ module Masks
|
|
|
39
39
|
say ""
|
|
40
40
|
|
|
41
41
|
engine? ? say_engine_database : say_server_database
|
|
42
|
+
say_cache_store if engine?
|
|
42
43
|
|
|
43
44
|
say "In production, masks keeps the address its setup screen was first opened at, and builds the"
|
|
44
45
|
say "issuer and every emailed link from it. To name the address yourself, set"
|
|
@@ -105,6 +106,24 @@ module Masks
|
|
|
105
106
|
say ""
|
|
106
107
|
end
|
|
107
108
|
|
|
109
|
+
def say_cache_store
|
|
110
|
+
say "masks holds one-time sign-in state, rate limits, and health findings in Rails.cache, so"
|
|
111
|
+
say "every process that serves it must share one cache store, such as :solid_cache_store,"
|
|
112
|
+
say ":redis_cache_store or :mem_cache_store. In production, masks refuses to boot on"
|
|
113
|
+
say ":memory_store or :null_store."
|
|
114
|
+
|
|
115
|
+
if Configuration.unshared_cache_store?(cache_store)
|
|
116
|
+
say ""
|
|
117
|
+
say "This app's cache_store is #{Array(cache_store).first.inspect}. Change it before you deploy.", :yellow
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
say ""
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def cache_store
|
|
124
|
+
::Rails.application&.config&.cache_store if defined?(::Rails.application)
|
|
125
|
+
end
|
|
126
|
+
|
|
108
127
|
def say_server_database
|
|
109
128
|
say "masks keeps its tables in the primary database. Connect as a role holding neither"
|
|
110
129
|
say "SUPERUSER nor BYPASSRLS, and set config.active_record.schema_format = :sql, since"
|
|
@@ -4,6 +4,7 @@ module Masks
|
|
|
4
4
|
module Server
|
|
5
5
|
module Configuration
|
|
6
6
|
MODES = %i[server engine].freeze
|
|
7
|
+
UNSHARED_CACHE_STORES = %i[memory_store null_store].freeze
|
|
7
8
|
|
|
8
9
|
module_function
|
|
9
10
|
|
|
@@ -12,13 +13,6 @@ module Masks
|
|
|
12
13
|
config.mode = ENV.fetch("MASKS_MODE", "server").to_sym
|
|
13
14
|
config.database = nil
|
|
14
15
|
config.secret = nil
|
|
15
|
-
config.attempt_limit = ENV.fetch("MASKS_ATTEMPT_LIMIT", 10).to_i
|
|
16
|
-
config.account_attempt_limit = ENV.fetch("MASKS_ACCOUNT_ATTEMPT_LIMIT", 5).to_i
|
|
17
|
-
config.registration_limit = ENV.fetch("MASKS_REGISTRATION_LIMIT", 10).to_i
|
|
18
|
-
config.recovery_limit = ENV.fetch("MASKS_RECOVERY_LIMIT", 5).to_i
|
|
19
|
-
config.device_code_limit = ENV.fetch("MASKS_DEVICE_CODE_LIMIT", 30).to_i
|
|
20
|
-
config.claim_limit = ENV.fetch("MASKS_CLAIM_LIMIT", 3).to_i
|
|
21
|
-
config.claim_ceiling = ENV.fetch("MASKS_CLAIM_CEILING", 30).to_i
|
|
22
16
|
config.setup_token = ENV["MASKS_SETUP_TOKEN"].presence
|
|
23
17
|
config.recover = ENV["MASKS_RECOVER"].presence
|
|
24
18
|
config.config_path = ENV["MASKS_CONFIG"].presence
|
|
@@ -88,7 +82,11 @@ module Masks
|
|
|
88
82
|
}.compact
|
|
89
83
|
end
|
|
90
84
|
|
|
91
|
-
def
|
|
85
|
+
def unshared_cache_store?(cache_store)
|
|
86
|
+
UNSHARED_CACHE_STORES.include?(Array(cache_store).first&.to_sym)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate!(config, local:, building:, cache_store: nil)
|
|
92
90
|
unless MODES.include?(config.mode)
|
|
93
91
|
raise "masks runs in server or engine mode, and config.masks.mode is #{config.mode.inspect}."
|
|
94
92
|
end
|
|
@@ -131,6 +129,14 @@ module Masks
|
|
|
131
129
|
raise "masks in engine mode needs a database of its own. Set config.masks.database to " \
|
|
132
130
|
"the name of a database.yml entry whose role cannot bypass row-level security."
|
|
133
131
|
end
|
|
132
|
+
|
|
133
|
+
if unshared_cache_store?(cache_store)
|
|
134
|
+
raise "config.cache_store is #{Array(cache_store).first.inspect}, which every process keeps " \
|
|
135
|
+
"to itself. masks holds one-time sign-in state, rate limits, and health findings in " \
|
|
136
|
+
"Rails.cache, so a provider's callback redeemed in one process is lost in the next. " \
|
|
137
|
+
"Use a store every process shares, such as :solid_cache_store, :redis_cache_store " \
|
|
138
|
+
"or :mem_cache_store."
|
|
139
|
+
end
|
|
134
140
|
end
|
|
135
141
|
end
|
|
136
142
|
end
|
data/lib/masks/server/engine.rb
CHANGED
|
@@ -20,7 +20,8 @@ module Masks
|
|
|
20
20
|
Configuration.validate!(
|
|
21
21
|
app.config.masks,
|
|
22
22
|
local: ::Rails.env.local?,
|
|
23
|
-
building: ENV["SECRET_KEY_BASE_DUMMY"].present
|
|
23
|
+
building: ENV["SECRET_KEY_BASE_DUMMY"].present?,
|
|
24
|
+
cache_store: app.config.cache_store
|
|
24
25
|
)
|
|
25
26
|
|
|
26
27
|
middleware.insert_before Tenancy::Middleware, Isolation if app.config.masks.mode == :engine
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Server
|
|
3
3
|
class Forwarding
|
|
4
|
-
HEADERS = %w[
|
|
4
|
+
HEADERS = %w[
|
|
5
|
+
HTTP_X_FORWARDED_FOR HTTP_X_FORWARDED_HOST HTTP_CLIENT_IP HTTP_FORWARDED
|
|
6
|
+
HTTP_X_FORWARDED_PROTO HTTP_X_FORWARDED_SCHEME HTTP_X_FORWARDED_SSL HTTP_X_FORWARDED_PORT
|
|
7
|
+
].freeze
|
|
8
|
+
PROTO = "HTTP_X_FORWARDED_PROTO".freeze
|
|
5
9
|
|
|
6
10
|
def initialize(app, proxies)
|
|
7
11
|
@app = app
|
|
@@ -9,9 +13,20 @@ module Masks
|
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def call(env)
|
|
12
|
-
|
|
16
|
+
address = env["REMOTE_ADDR"]
|
|
17
|
+
trusted = trusted?(address)
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
unless trusted
|
|
20
|
+
Health.note_proxy!("untrusted", address) if HEADERS.any? { |header| env.key?(header) }
|
|
21
|
+
HEADERS.each { |header| env.delete(header) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
plain = trusted && plain?(env)
|
|
25
|
+
status, headers, body = @app.call(env)
|
|
26
|
+
|
|
27
|
+
Health.note_proxy!("plain", address) if plain && !sent_to_https?(status, headers)
|
|
28
|
+
|
|
29
|
+
[ status, headers, body ]
|
|
15
30
|
end
|
|
16
31
|
|
|
17
32
|
private
|
|
@@ -24,6 +39,14 @@ module Masks
|
|
|
24
39
|
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
25
40
|
false
|
|
26
41
|
end
|
|
42
|
+
|
|
43
|
+
def plain?(env)
|
|
44
|
+
env[PROTO].to_s.split(",").first.to_s.strip.casecmp?("http")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def sent_to_https?(status, headers)
|
|
48
|
+
(300..399).cover?(status) && headers["location"].to_s.downcase.start_with?("https://")
|
|
49
|
+
end
|
|
27
50
|
end
|
|
28
51
|
end
|
|
29
52
|
end
|
|
@@ -6,7 +6,6 @@ module Masks
|
|
|
6
6
|
MOVED = "masks answers at %s. To serve it at another address, set MASKS_PUBLIC_ORIGIN_TEMPLATE to that address.".freeze
|
|
7
7
|
UNKEPT = "This tenant was set up without a kept address. Set MASKS_PUBLIC_ORIGIN_TEMPLATE to the address it answers at.".freeze
|
|
8
8
|
CROWDED = "too many tenants were claimed recently; try again later".freeze
|
|
9
|
-
CLAIM_WINDOW = 1.hour
|
|
10
9
|
TENANTLESS = %w[/up /metrics].freeze
|
|
11
10
|
TLS_ASK = "/tls/allowed".freeze
|
|
12
11
|
|
|
@@ -94,18 +93,16 @@ module Masks
|
|
|
94
93
|
end
|
|
95
94
|
|
|
96
95
|
def crowded?(request)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
from_anywhere = ::Rails.cache.increment("tenant-claims:#{window}", 1, expires_in: CLAIM_WINDOW)
|
|
96
|
+
here = Throttle.crowded?(:claim, "claims:#{request.remote_ip}")
|
|
97
|
+
anywhere = Throttle.crowded?(:claim_ceiling, "claims")
|
|
100
98
|
|
|
101
|
-
|
|
102
|
-
from_anywhere.to_i > ::Rails.configuration.masks.claim_ceiling
|
|
99
|
+
(here || anywhere).tap { |crowded| Throttle.hit!(here ? :claim : :claim_ceiling, address: request.remote_ip) if crowded }
|
|
103
100
|
end
|
|
104
101
|
|
|
105
102
|
def crowded
|
|
106
103
|
[
|
|
107
104
|
429,
|
|
108
|
-
{ "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store", "retry-after" =>
|
|
105
|
+
{ "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store", "retry-after" => Throttle.budget(:claim).within.to_i.to_s },
|
|
109
106
|
[ CROWDED ]
|
|
110
107
|
]
|
|
111
108
|
end
|