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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/masks/server/scim_endpoint.rb +2 -2
  3. data/app/controllers/masks/server/account_exports_controller.rb +2 -2
  4. data/app/controllers/masks/server/addresses_controller.rb +2 -3
  5. data/app/controllers/masks/server/authenticator_apps_controller.rb +2 -3
  6. data/app/controllers/masks/server/authorize_controller.rb +2 -2
  7. data/app/controllers/masks/server/device_authorizations_controller.rb +2 -2
  8. data/app/controllers/masks/server/device_verifications_controller.rb +2 -2
  9. data/app/controllers/masks/server/enrollments_controller.rb +4 -4
  10. data/app/controllers/masks/server/forward_auth_controller.rb +2 -2
  11. data/app/controllers/masks/server/introspections_controller.rb +2 -2
  12. data/app/controllers/masks/server/logins_controller.rb +10 -12
  13. data/app/controllers/masks/server/organization_members_controller.rb +2 -2
  14. data/app/controllers/masks/server/passwords_controller.rb +2 -3
  15. data/app/controllers/masks/server/pushed_authorizations_controller.rb +2 -2
  16. data/app/controllers/masks/server/received_signals_controller.rb +2 -2
  17. data/app/controllers/masks/server/registrations_controller.rb +2 -2
  18. data/app/controllers/masks/server/revocations_controller.rb +2 -2
  19. data/app/controllers/masks/server/saml_controller.rb +2 -2
  20. data/app/controllers/masks/server/sign_in_approvals_controller.rb +2 -3
  21. data/app/controllers/masks/server/tokens_controller.rb +2 -2
  22. data/app/controllers/masks/server/verifications_controller.rb +2 -3
  23. data/app/graphql/masks/server/manage/types/health_check_type.rb +1 -1
  24. data/app/ldap/masks/server/ldap/gate.rb +14 -16
  25. data/app/models/masks/server/app_passwords.rb +7 -13
  26. data/app/models/masks/server/client_metadata_document.rb +4 -8
  27. data/app/models/masks/server/confirmation_code.rb +2 -2
  28. data/app/models/masks/server/declared.rb +3 -1
  29. data/app/models/masks/server/event.rb +2 -2
  30. data/app/models/masks/server/health.rb +70 -3
  31. data/app/models/masks/server/saml_identity/farewells.rb +1 -0
  32. data/app/models/masks/server/tenant.rb +48 -4
  33. data/app/models/masks/server/throttle.rb +165 -0
  34. data/config/locales/de/manage/health.yml +9 -2
  35. data/config/locales/en/manage/health.yml +9 -2
  36. data/config/locales/es/manage/health.yml +9 -2
  37. data/config/locales/fr/manage/health.yml +9 -2
  38. data/db/migrate/20261031100000_default_key_rotation_to_ninety_days.rb +5 -0
  39. data/lib/generators/masks/server/install/install_generator.rb +19 -0
  40. data/lib/masks/server/configuration.rb +14 -8
  41. data/lib/masks/server/engine.rb +2 -1
  42. data/lib/masks/server/forwarding.rb +26 -3
  43. data/lib/masks/server/tenancy/middleware.rb +4 -7
  44. data/lib/masks/server/tenant_isolation.rb +12 -0
  45. data/version.txt +1 -1
  46. 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 ok("keys", "manual", days: days) if period.nil?
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, false)", "tenant",
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. Der aktive Schlüssel ist %{days} Tage alt."
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: "Rotieren Sie den Schlüssel auf der Seite Einstellungen, oder lesen Sie den Job signing_keys im Serverprotokoll."
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. The active key is %{days} days old."
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: "Rotate the key on the Settings page, or read the signing_keys job in the server log."
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. La clave activa tiene %{days} días."
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: "Rota la clave en la página Configuración, o lee la tarea signing_keys en el registro del servidor."
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. La clé active a %{days} jours."
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: "Faites tourner la clé sur la page Paramètres, ou lisez la tâche signing_keys dans le journal du serveur."
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."
@@ -0,0 +1,5 @@
1
+ class DefaultKeyRotationToNinetyDays < ActiveRecord::Migration[8.1]
2
+ def change
3
+ change_column_default :tenants, :key_rotation_days, from: nil, to: 90
4
+ end
5
+ end
@@ -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 validate!(config, local:, building:)
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
@@ -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[HTTP_X_FORWARDED_FOR HTTP_X_FORWARDED_HOST HTTP_CLIENT_IP HTTP_FORWARDED].freeze
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
- HEADERS.each { |header| env.delete(header) } unless trusted?(env["REMOTE_ADDR"])
16
+ address = env["REMOTE_ADDR"]
17
+ trusted = trusted?(address)
13
18
 
14
- @app.call(env)
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
- window = Time.current.to_i / CLAIM_WINDOW.to_i
98
- from_here = ::Rails.cache.increment("tenant-claims:#{window}:#{request.remote_ip}", 1, expires_in: CLAIM_WINDOW)
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
- from_here.to_i > ::Rails.configuration.masks.claim_limit ||
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" => CLAIM_WINDOW.to_i.to_s },
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