runlight 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module Runlight
4
6
  module Accounts
5
7
  # Accounts: who may sign in, their password hashes, two-factor, invites, and the signed cookie that keeps them
@@ -32,14 +34,50 @@ module Runlight
32
34
  # A password checked against nothing, so a wrong email takes as long as a wrong password. Made when first needed.
33
35
  @decoy = nil
34
36
 
37
+ # Password checks run at once, in this process, and the most that may wait for one.
38
+ CHECKING = 4
39
+ WAITING = 64
40
+ SLOTS = Mutex.new
41
+ FREED = ConditionVariable.new
42
+ private_constant :SLOTS, :FREED
43
+ @running = 0
44
+ @waiting = 0
45
+
35
46
  class << self
36
47
  attr_accessor :decoy
48
+
49
+ # Runs a password check when one of the few slots is free, so a flood of sign-ins cannot take every core.
50
+ # Past the queue's length it refuses at once, with an AccountError coded "busy".
51
+ def slot
52
+ SLOTS.synchronize do
53
+ if @running >= CHECKING
54
+ raise AccountError.new("Runlight is busy checking other sign-ins. Try again in a moment.", "busy") if @waiting >= WAITING
55
+
56
+ @waiting += 1
57
+ begin
58
+ FREED.wait(SLOTS) while @running >= CHECKING
59
+ ensure
60
+ @waiting -= 1
61
+ end
62
+ end
63
+ @running += 1
64
+ end
65
+ begin
66
+ yield
67
+ ensure
68
+ SLOTS.synchronize do
69
+ @running -= 1
70
+ FREED.signal
71
+ end
72
+ end
73
+ end
37
74
  end
38
75
 
39
76
  def initialize(store, secret)
40
77
  @store = store
41
78
  @secret = secret
42
79
  @ready = false
80
+ @init_lock = Monitor.new
43
81
  end
44
82
 
45
83
  # A stored role read back; anything unknown reads as a viewer, the least it could be.
@@ -47,6 +85,11 @@ module Runlight
47
85
  ROLES.include?(value) ? value : "viewer"
48
86
  end
49
87
 
88
+ # Whether a role may invite people. The owner and admins invite as admin, member, or viewer.
89
+ def self.may_invite?(role)
90
+ role == "owner" || role == "admin"
91
+ end
92
+
50
93
  # Starts turning on two-factor: a new secret, kept aside until a code from it is confirmed.
51
94
  def start_two_factor(id)
52
95
  init
@@ -181,6 +224,8 @@ module Runlight
181
224
  raise AccountError.new("Ownership is handed over by the owner", "owner_hand_over") if role == "owner"
182
225
 
183
226
  @store.db.run("UPDATE rl_users SET role = ? WHERE id = ?", [role, id])
227
+ # Someone who may no longer invite takes back the invites they sent, so nobody joins on their word.
228
+ @store.db.run("DELETE FROM rl_invites WHERE invited_by = ?", [user["email"]]) unless self.class.may_invite?(role)
184
229
  user["role"] = role
185
230
  user
186
231
  end
@@ -212,6 +257,7 @@ module Runlight
212
257
  raise AccountError.new("The owner cannot be removed", "owner_protected") if user["role"] == "owner"
213
258
 
214
259
  @store.db.run("DELETE FROM rl_users WHERE id = ?", [id])
260
+ @store.db.run("DELETE FROM rl_invites WHERE invited_by = ?", [user["email"]])
215
261
  @store.set_setting("login-link-used:#{id}", nil)
216
262
  end
217
263
  nil
@@ -280,27 +326,44 @@ module Runlight
280
326
  before.positive?
281
327
  end
282
328
 
283
- # Turns an invite into an account with the password its person chose. The link then stops working.
329
+ # Turns an invite into an account with the password its person chose. The link then stops working. It takes a
330
+ # turn, so it cannot cross a removal of whoever sent it, and the sender must still be someone who may invite.
284
331
  def accept_invite(code, password, now)
285
- invite = invite_by_code(code, now)
286
- raise AccountError.new("This invite has expired or was already used. Ask for a new one.", "invite_gone") if invite.nil?
287
- unless by_email(invite["email"]).nil?
288
- raise AccountError.new("#{invite["email"]} already has an account", "account_exists", { "email" => invite["email"] })
332
+ # The tables first: making them takes the same lock as a turn.
333
+ init
334
+ user = turn do
335
+ invite = invite_by_code(code, now)
336
+ next nil if invite.nil?
337
+
338
+ sender = by_email(invite["invitedBy"])
339
+ if sender.nil? || !self.class.may_invite?(sender["role"])
340
+ # Left without raising, since an error inside a turn on SQLite would roll this back.
341
+ @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
342
+ next nil
343
+ end
344
+ unless by_email(invite["email"]).nil?
345
+ raise AccountError.new("#{invite["email"]} already has an account", "account_exists", { "email" => invite["email"] })
346
+ end
347
+
348
+ made = set_password(invite["email"], password, now, invite["role"])
349
+ @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
350
+ made
289
351
  end
352
+ raise AccountError.new("This invite has expired or was already used. Ask for a new one.", "invite_gone") if user.nil?
290
353
 
291
- user = set_password(invite["email"], password, now, invite["role"])
292
- @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
293
354
  user
294
355
  end
295
356
 
296
- # The account for an email and password, or nil. Takes the same time either way.
357
+ # The account for an email and password, or nil. Takes the same time either way. Only a few checks run at
358
+ # once; with too many waiting it raises an AccountError coded "busy".
297
359
  def sign_in(email, password)
298
360
  user = by_email(email)
299
361
  if user.nil?
300
- Crypto.check_password(password, self.class.decoy ||= Crypto.hash_password(Crypto.hex(Crypto.random_bytes(16))))
362
+ hash = self.class.decoy ||= Crypto.hash_password(Crypto.hex(Crypto.random_bytes(16)))
363
+ self.class.slot { Crypto.check_password(password, hash) }
301
364
  return nil
302
365
  end
303
- Crypto.check_password(password, user["hash"]) ? user : nil
366
+ self.class.slot { Crypto.check_password(password, user["hash"]) } ? user : nil
304
367
  end
305
368
 
306
369
  # A cookie value naming the user and when it expires, signed with the
@@ -348,15 +411,21 @@ module Runlight
348
411
 
349
412
  private
350
413
 
351
- # Changes to who has an account take turns: on Postgres and MySQL across processes, through the database's
352
- # lock, so two owners demoting each other at once cannot leave none, and a double-clicked invite makes one.
414
+ # Changes to who has an account take turns across threads and processes (the database's lock, or on SQLite a
415
+ # transaction that takes the write lock first), so two owners demoting each other at once cannot leave none,
416
+ # a double-clicked invite makes one, and a code or a sign-in link works once.
353
417
  def turn(&fn)
354
- @store.db.exclusive { fn.call }
418
+ db = @store.db
419
+ db.respond_to?(:turn) ? db.turn { fn.call } : db.exclusive { fn.call }
355
420
  end
356
421
 
357
422
  def init
358
423
  return if @ready
359
424
 
425
+ @init_lock.synchronize { init_now unless @ready }
426
+ end
427
+
428
+ def init_now
360
429
  # Several processes starting at once create the tables one at a time.
361
430
  @store.db.exclusive do
362
431
  db = @store.db
@@ -3,15 +3,24 @@
3
3
  module Runlight
4
4
  module Accounts
5
5
  # Counts failed sign-ins under a key and refuses more than a few in a while.
6
- # Keys are hashed with a key made on first use, so the counts never hold an
7
- # address or an email as it was given.
6
+ # Keys are hashed with a key made for each UTC day, so the counts never hold
7
+ # an address or an email as it was given.
8
8
  #
9
9
  # TypeScript keeps the counts in its process. Here they live in the database's
10
10
  # settings, as "throttle:<name>:<id>" holding {count, until}, and the hashing
11
- # key as "throttle-key", so every process of an install shares the same counts.
11
+ # key as "throttle-key:<UTC day>", so every process of an install shares the
12
+ # same counts. Some keys are addresses, and nothing derived from one lasts
13
+ # longer than a day: an expired count is deleted when it is read and by the
14
+ # scheduled check (sweep), and a day's hashing key goes once the day is over
15
+ # (yesterday's is kept only long enough to carry a count across midnight).
12
16
  class Throttle
13
- KEY = "throttle-key"
14
- private_constant :KEY
17
+ KEY = "throttle-key:"
18
+ # The one hashing key from before keys were made per day.
19
+ LEGACY_KEY = "throttle-key"
20
+ DAY_MS = 86_400_000
21
+ # How long after midnight yesterday's key carries a count over: the longest window a throttle counts in.
22
+ CARRY_MS = 15 * 60_000
23
+ private_constant :KEY, :LEGACY_KEY, :DAY_MS, :CARRY_MS
15
24
 
16
25
  def initialize(store, name, limit = 10, window_ms = 15 * 60_000)
17
26
  @store = store
@@ -20,8 +29,35 @@ module Runlight
20
29
  @window_ms = window_ms
21
30
  end
22
31
 
32
+ # Deletes every count that has run out and the hashing keys of days gone, for the scheduled check.
33
+ def self.sweep(store, now)
34
+ store.settings_starting_with("throttle:").each do |row|
35
+ entry = Json.try_decode(row["value"])
36
+ until_ms = entry.is_a?(Hash) ? Js.number(entry["until"].nil? ? 0 : entry["until"]) : 0
37
+ store.set_setting(row["key"], nil) unless until_ms.is_a?(Numeric) && until_ms.finite? && until_ms > now
38
+ end
39
+ drop_old_keys(store, now)
40
+ end
41
+
42
+ # The UTC day a time falls on, as YYYY-MM-DD.
43
+ def self.day(now)
44
+ Time.at(now.to_i / 1000).utc.strftime("%Y-%m-%d")
45
+ end
46
+
47
+ # Keeps today's hashing key, and yesterday's only just after midnight; the one from before days goes too.
48
+ def self.drop_old_keys(store, now)
49
+ keep = [day(now)]
50
+ keep << day(now - DAY_MS) if now.to_i % DAY_MS < CARRY_MS
51
+ store.set_setting(LEGACY_KEY, nil) unless store.setting(LEGACY_KEY).nil?
52
+ store.settings_starting_with(KEY).each do |row|
53
+ store.set_setting(row["key"], nil) unless keep.include?(row["key"][KEY.length..])
54
+ end
55
+ end
56
+
23
57
  def blocked(key, now)
24
- blocked_id?(id(key), now)
58
+ today, yesterday = ids(key, now)
59
+ entry = entry(today, now) || (yesterday && entry(yesterday, now))
60
+ blocked_entry?(entry)
25
61
  end
26
62
 
27
63
  # Counts a try before the slow check it guards, so a burst that arrives
@@ -29,43 +65,75 @@ module Runlight
29
65
  # False, counting nothing, when the key is already at its limit. A try
30
66
  # that turns out right is taken back with forgive().
31
67
  def take(key, now)
32
- id = id(key)
33
- return false if blocked_id?(id, now)
34
-
35
- count(id, now)
36
- true
68
+ ids = ids(key, now)
69
+ # Checked and counted in one turn, so tries at once from other threads and processes wait for it.
70
+ turn do
71
+ entry = carry(ids, now)
72
+ next false if blocked_entry?(entry)
73
+
74
+ count(ids[0], entry, now)
75
+ true
76
+ end
37
77
  end
38
78
 
39
79
  # Takes back one counted try, for one that turned out right.
40
- def forgive(key)
41
- id = id(key)
42
- entry = entry(id)
43
- return unless entry && entry["count"].positive?
44
-
45
- entry["count"] -= 1
46
- save(id, entry)
80
+ def forgive(key, now)
81
+ ids = ids(key, now)
82
+ turn do
83
+ entry = carry(ids, now)
84
+ next unless entry && entry["count"].positive?
85
+
86
+ entry["count"] -= 1
87
+ save(ids[0], entry)
88
+ end
89
+ nil
47
90
  end
48
91
 
49
92
  def record_failure(key, now)
50
- count(id(key), now)
93
+ ids = ids(key, now)
94
+ turn { count(ids[0], carry(ids, now), now) }
95
+ nil
51
96
  end
52
97
 
53
- def clear(key)
54
- @store.set_setting("#{prefix}#{id(key)}", nil)
98
+ def clear(key, now)
99
+ ids(key, now).compact.each { |id| @store.set_setting("#{prefix}#{id}", nil) }
100
+ nil
55
101
  end
56
102
 
57
103
  private
58
104
 
59
- def salt
60
- saved = @store.setting(KEY)
105
+ # The day's hashing key, made the first time it is needed (unless make is false, for yesterday's).
106
+ def salt(now, make = true)
107
+ name = "#{KEY}#{self.class.day(now)}"
108
+ saved = @store.setting(name)
61
109
  return saved if saved && saved != ""
110
+ return nil unless make
62
111
 
63
- made = Hashing.random_id(16)
64
- @store.set_setting(KEY, made)
65
- made
112
+ # Made in a turn, so two processes starting at once do not each keep a different one.
113
+ turn do
114
+ saved = @store.setting(name)
115
+ next saved if saved && saved != ""
116
+
117
+ made = Hashing.random_id(16)
118
+ @store.set_setting(name, made)
119
+ self.class.drop_old_keys(@store, now)
120
+ made
121
+ end
122
+ end
123
+
124
+ # One change to the counts at a time, across threads and processes, as the accounts take turns.
125
+ def turn(&block)
126
+ db = @store.db
127
+ db.respond_to?(:turn) ? db.turn { block.call } : db.exclusive { block.call }
66
128
  end
67
129
 
68
- def id(key)
130
+ # The key's id under today's hashing key, and under yesterday's while that is still kept (else nil).
131
+ def ids(key, now)
132
+ yesterday = salt(now - DAY_MS, false)
133
+ [keyed(salt(now), key), yesterday.nil? ? nil : keyed(yesterday, key)]
134
+ end
135
+
136
+ def keyed(salt, key)
69
137
  Crypto.base64url(Crypto.hmac("SHA-256", salt, key))[0, 22]
70
138
  end
71
139
 
@@ -73,10 +141,31 @@ module Runlight
73
141
  "throttle:#{@name}:"
74
142
  end
75
143
 
76
- def entry(id)
144
+ # A count that has not run out, or nil. One that has is deleted as it is read.
145
+ def entry(id, now)
77
146
  saved = @store.setting("#{prefix}#{id}")
78
- entry = saved.nil? ? nil : Json.try_decode(saved)
79
- entry.is_a?(Hash) ? { "count" => int(entry["count"]), "until" => int(entry["until"]) } : nil
147
+ return nil if saved.nil?
148
+
149
+ entry = Json.try_decode(saved)
150
+ entry = entry.is_a?(Hash) ? { "count" => int(entry["count"]), "until" => int(entry["until"]) } : nil
151
+ return entry if entry && entry["until"] > now
152
+
153
+ @store.set_setting("#{prefix}#{id}", nil)
154
+ nil
155
+ end
156
+
157
+ # Today's count, or yesterday's moved under today's id, so a window that crosses midnight goes on.
158
+ def carry(ids, now)
159
+ today, yesterday = ids
160
+ found = entry(today, now)
161
+ return found if found || yesterday.nil?
162
+
163
+ found = entry(yesterday, now)
164
+ return nil if found.nil?
165
+
166
+ @store.set_setting("#{prefix}#{yesterday}", nil)
167
+ save(today, found)
168
+ found
80
169
  end
81
170
 
82
171
  # A saved number, whole, and 0 for anything that is not a number.
@@ -89,16 +178,12 @@ module Runlight
89
178
  @store.set_setting("#{prefix}#{id}", Json.encode(entry))
90
179
  end
91
180
 
92
- def blocked_id?(id, now)
93
- entry = entry(id)
94
- return false if entry.nil? || entry["until"] <= now
95
-
96
- entry["count"] >= @limit
181
+ def blocked_entry?(entry)
182
+ !entry.nil? && entry["count"] >= @limit
97
183
  end
98
184
 
99
- def count(id, now)
100
- entry = entry(id)
101
- if entry.nil? || entry["until"] <= now
185
+ def count(id, entry, now)
186
+ if entry.nil?
102
187
  save(id, { "count" => 1, "until" => now + @window_ms })
103
188
  prune(now)
104
189
  return
@@ -44,16 +44,22 @@ module Runlight
44
44
  @home_path = "#{@base}/"
45
45
  @asks_for_token = @first.is_a?(Hash) && @first.key?("token")
46
46
  @existing = false
47
- # Wrong passwords are counted twice. Per account and address, ten tries;
47
+ # Wrong passwords are counted three times. Per account and address, ten tries;
48
48
  # per account from anywhere, fifty, so a caller who invents a new address
49
49
  # for every try still cannot guess on and on. Addresses come from
50
- # forwarding headers a client can write, so they never stand alone.
50
+ # forwarding headers a client can write, so they never stand alone. And per
51
+ # address across every account, thirty, so one caller trying made-up emails
52
+ # cannot keep the server hashing; a request with no known address skips that one,
53
+ # since it would hold up everyone at once.
51
54
  # Each try counts before the password is checked, and a right one is taken back.
52
55
  @per_address = Throttle.new(@store, "address", 10)
53
56
  @per_account = Throttle.new(@store, "account", 50)
57
+ @per_ip = Throttle.new(@store, "ip", 30)
54
58
  # Six-digit codes: five wrong tries an account every fifteen minutes, and five to confirm the first one.
55
59
  # Password re-checks in Account: ten.
56
60
  @code_tries = Throttle.new(@store, "code", 5)
61
+ # A decoy ticket's tries count apart, the same way, so someone guessing passwords cannot use up the owner's five.
62
+ @decoy_tries = Throttle.new(@store, "decoy", 5)
57
63
  @confirm_tries = Throttle.new(@store, "confirm", 5)
58
64
  @rechecks = Throttle.new(@store, "recheck", 10)
59
65
  end
@@ -70,21 +76,26 @@ module Runlight
70
76
  # Only a path on this install, so a sign-in can never send someone elsewhere.
71
77
  # Browsers drop tabs and newlines from a URL and read a backslash as a slash,
72
78
  # so "/\t/evil.example" would leave; anything with those is refused outright,
73
- # and what is left must resolve to this origin.
79
+ # and what is left must resolve to this origin. A path that resolves to one
80
+ # starting "//", as "/..//evil.example" does, would leave from the Location
81
+ # header, so that goes home too.
74
82
  def safe_next(value)
75
83
  return @home_path if value.nil? || value == "" || !value.start_with?("/") || value.match?(/[\x00-\x1f\x7f\\]/)
76
84
 
77
85
  url = Http::Url.parse(value, "http://runlight.invalid")
78
- !url.nil? && url.origin == "http://runlight.invalid" ? "#{url.pathname}#{url.search}#{url.hash}" : @home_path
86
+ return @home_path if url.nil? || url.origin != "http://runlight.invalid"
87
+
88
+ path = "#{url.pathname}#{url.search}#{url.hash}"
89
+ path.start_with?("//") || path.start_with?("/\\") ? @home_path : path
79
90
  end
80
91
 
81
92
  def signed_in(request)
82
93
  value = read_cookie(request, Accounts::SESSION_COOKIE)
83
94
  return nil if value == ""
84
95
 
96
+ # A cookie that is not valid percent-encoding is nobody's session, never an error.
85
97
  decoded = Js.decode_uri_component(value)
86
- # decodeURIComponent throws a URIError here in TypeScript.
87
- raise ArgumentError, "URI malformed" if decoded.nil?
98
+ return nil if decoded.nil?
88
99
 
89
100
  @accounts.from_session(decoded, now)
90
101
  end
@@ -143,6 +154,12 @@ module Runlight
143
154
  @now.call
144
155
  end
145
156
 
157
+ # The store's tables, settings among them, which the throttles keep their counts in (TypeScript keeps them
158
+ # in memory), so a fresh install's first sign-in or two-factor change finds them there.
159
+ def ready
160
+ @rl.init if @rl.respond_to?(:init)
161
+ end
162
+
146
163
  def home_origin
147
164
  @home&.call
148
165
  end
@@ -378,6 +395,7 @@ module Runlight
378
395
  end
379
396
 
380
397
  if path == "/login/code" && method == "POST"
398
+ ready
381
399
  form = Http::SearchParams.new(request.text)
382
400
  next_path = safe_next(form.get("next"))
383
401
  pending = @accounts.from_pending(form.get("pending") || "", now)
@@ -386,7 +404,7 @@ module Runlight
386
404
  user = pending["user"]
387
405
  real = pending["real"]
388
406
  # Counted before the check, so a burst cannot get past five.
389
- unless @code_tries.take(user["id"], now)
407
+ unless (real ? @code_tries : @decoy_tries).take(user["id"], now)
390
408
  return html(Pages.code_page(base, { "pending" => form.get("pending") || "", "next" => next_path, "error" => "Too many tries. Wait fifteen minutes and try again." }), 429)
391
409
  end
392
410
  if !real || !@accounts.check_second_factor(user["id"], form.get("code") || "", now)
@@ -394,7 +412,7 @@ module Runlight
394
412
  "error" => "That code is not right. Check the time on your phone, or use a recovery code." }), 401)
395
413
  end
396
414
 
397
- @code_tries.clear(user["id"])
415
+ @code_tries.clear(user["id"], now)
398
416
  return signed_in_to(request, user, next_path)
399
417
  end
400
418
 
@@ -424,6 +442,8 @@ module Runlight
424
442
  @existing = true
425
443
  return redirect(@home_path, { "set-cookie" => session_cookie(request, @accounts.session_for(user, now), Accounts::SESSION_MS / 1000) })
426
444
  rescue RangeError => e
445
+ return html(Pages.invite_gone_page(base), 410) if e.is_a?(AccountError) && e.code == "invite_gone"
446
+
427
447
  return again.call(e.message)
428
448
  end
429
449
  end
@@ -432,6 +452,7 @@ module Runlight
432
452
  end
433
453
 
434
454
  def login(request, context)
455
+ ready
435
456
  base = @base
436
457
  form = Http::SearchParams.new(request.text)
437
458
  email = form.get("email") || ""
@@ -442,6 +463,7 @@ module Runlight
442
463
  pair = "#{account}\n#{ip == "" ? "unknown" : ip}"
443
464
  login_page = ->(opts) { Pages.login_page(base, opts.merge("next" => next_path, "forgot" => @forgot) { |_k, mine, _theirs| mine }) }
444
465
  too_many = -> { html(login_page.call({ "error" => "Too many tries. Wait fifteen minutes and try again.", "email" => email }), 429) }
466
+ return too_many.call if ip != "" && !@per_ip.take(ip, now)
445
467
  return too_many.call unless @per_address.take(pair, now)
446
468
 
447
469
  # A browser that signed in to the account before is never held up by others' failures.
@@ -474,12 +496,18 @@ module Runlight
474
496
 
475
497
  return html(login_page.call({ "error" => "That email and password do not match an account.", "email" => email }), 401)
476
498
  end
477
- @per_address.clear(pair)
478
- @per_account.forgive(account) if !over && !trusted
499
+ @per_address.clear(pair, now)
500
+ @per_ip.forgive(ip, now) if ip != ""
501
+ @per_account.forgive(account, now) if !over && !trusted
479
502
  # With two-factor on, the password only earns the second step.
480
503
  return html(Pages.code_page(base, { "pending" => @accounts.pending_for(user, now), "next" => next_path })) if user["twoFactor"]
481
504
 
482
505
  signed_in_to(request, user, next_path)
506
+ rescue AccountError => e
507
+ # Too many password checks already waiting, so this one was never run: try again in a moment.
508
+ raise unless e.code == "busy"
509
+
510
+ html(login_page.call({ "error" => e.message, "email" => email }), 503, { "retry-after" => "5" })
483
511
  end
484
512
 
485
513
  # Your own account, and for the owner and admins, everyone else's.
@@ -487,6 +515,7 @@ module Runlight
487
515
  user = signed_in(request)
488
516
  return coded("Sign in first", "sign_in", 401) if user.nil?
489
517
 
518
+ ready
490
519
  now = self.now
491
520
  method = request.method
492
521
  # Writes must be JSON, which a form on another page cannot send, even those with no body.
@@ -495,9 +524,16 @@ module Runlight
495
524
 
496
525
  recheck = lambda do |input, name, wrong|
497
526
  return coded("Too many tries. Wait fifteen minutes and try again.", "too_many_tries", 429) unless @rechecks.take(user["id"], now)
498
- return coded(wrong[0], wrong[1], 400) if @accounts.sign_in(user["email"], field(input, name)).nil?
527
+ begin
528
+ right = @accounts.sign_in(user["email"], field(input, name))
529
+ rescue AccountError => e
530
+ raise unless e.code == "busy"
531
+
532
+ return coded("Runlight is busy checking other sign-ins. Try again in a moment.", "busy", 503)
533
+ end
534
+ return coded(wrong[0], wrong[1], 400) if right.nil?
499
535
 
500
- @rechecks.forgive(user["id"])
536
+ @rechecks.forgive(user["id"], now)
501
537
  nil
502
538
  end
503
539
  fresh = ->(updated) { { "set-cookie" => session_cookie(request, @accounts.session_for(updated, now), Accounts::SESSION_MS / 1000) } }
@@ -532,7 +568,7 @@ module Runlight
532
568
  codes = @accounts.confirm_two_factor(user["id"], field(input, "code").gsub(/[#{Js::SPACE}]/o, ""), now)
533
569
  return coded("That code is not right. Check the time on your phone and try the next one.", "code_wrong", 400) if codes.nil?
534
570
 
535
- @confirm_tries.clear(user["id"])
571
+ @confirm_tries.clear(user["id"], now)
536
572
  # Turning it on signs out every other browser; this one gets a new session.
537
573
  updated = @accounts.by_id(user["id"])
538
574
  return reply({ "recovery" => codes }, 200, fresh.call(updated))
@@ -541,7 +577,7 @@ module Runlight
541
577
  return refused unless refused.nil?
542
578
 
543
579
  if action == "/start"
544
- @confirm_tries.clear(user["id"])
580
+ @confirm_tries.clear(user["id"], now)
545
581
  secret = @accounts.start_two_factor(user["id"])
546
582
  return reply({ "secret" => secret, "uri" => Crypto.otpauth_uri(secret, user["email"], Http::Url.new(request.url).host) })
547
583
  end
@@ -85,8 +85,18 @@ module Runlight
85
85
  message.is_a?(String) ? message : ""
86
86
  end
87
87
 
88
+ # Refuses a service at a link-local or cloud metadata address. One on this computer (Ollama, LM Studio) or a
89
+ # private network is allowed, as the person setting it up means it to be.
90
+ def refuse_metadata(url)
91
+ host = Http::Url.new(url).hostname
92
+ return unless Safefetch.metadata_host?(host)
93
+
94
+ raise AssistantError.new("#{host} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused", { "host" => host })
95
+ end
96
+
88
97
  def post(fetcher, url, headers, body, deadline, now, cancelled)
89
98
  in_time(deadline, now, cancelled)
99
+ refuse_metadata(url)
90
100
  left = deadline - now.call
91
101
  begin
92
102
  answer = fetcher.fetch(url, {
@@ -293,6 +303,7 @@ module Runlight
293
303
  elsif key != "" then { "authorization" => "Bearer #{key}" }
294
304
  else {}
295
305
  end
306
+ refuse_metadata(base)
296
307
  begin
297
308
  answer = fetcher.fetch("#{base}/models#{provider["protocol"] == "anthropic" ? "?limit=100" : ""}", { "headers" => headers, "timeoutMs" => 20_000 })
298
309
  rescue StandardError
@@ -136,9 +136,8 @@ module Runlight
136
136
  def finish_connect(runlight, params)
137
137
  state = params.get("state") || ""
138
138
  key = "connect:#{state}"
139
- stored = state.match?(/\A[a-f0-9]{32}\z/) ? runlight.store.setting(key) : nil
140
- # Each attempt works once.
141
- runlight.store.set_setting(key, nil) if !stored.nil? && stored != ""
139
+ # Each attempt works once, even when it comes back twice at once.
140
+ stored = state.match?(/\A[a-f0-9]{32}\z/) ? runlight.store.take_setting(key) : nil
142
141
  pending = !stored.nil? && stored != "" ? pending_from(stored, runlight.now) : nil
143
142
  if pending.nil?
144
143
  raise ConnectError.new("That connection took too long or was already used. Start again.", "expired")
data/lib/runlight/core.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module Runlight
4
6
  # Runlight in an app: the sites it counts, the tracker endpoint's work, short links, email reports, and the
5
7
  # scheduled upkeep. A port of the TypeScript SDK's runlight.ts; `Runlight.new(options)` makes one.
@@ -132,6 +134,8 @@ module Runlight
132
134
  @remote_seen = {}
133
135
  @overrides = {}
134
136
  @ready = false
137
+ # init runs once however many threads ask at once; reentrant, as init may ask for itself.
138
+ @init_lock = Monitor.new
135
139
  @checking = false
136
140
  # When planner statistics were last gathered.
137
141
  @optimized_at = 0
@@ -211,6 +215,10 @@ module Runlight
211
215
  config = settings.merge("from" => from)
212
216
  config["fromName"] = from_name unless from_name == ""
213
217
  Mail::Transports.check_config(config)
218
+ if config["service"] == "smtp" && Js.truthy?(config["host"]) && Safefetch.metadata_host?(config["host"].to_s)
219
+ raise Mail::MailError.new("#{config["host"]} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused",
220
+ { "host" => config["host"] })
221
+ end
214
222
  @store.set_setting("mail", Mail::Secret.seal(Json.encode(config), @secret))
215
223
  end
216
224
 
@@ -275,6 +283,15 @@ module Runlight
275
283
  def init
276
284
  return if @ready
277
285
 
286
+ @init_lock.synchronize { init_now unless @ready }
287
+ end
288
+
289
+ # Whether init has run, so middleware can leave the app's own requests alone until it has.
290
+ def ready?
291
+ @ready
292
+ end
293
+
294
+ private def init_now
278
295
  @store.migrate
279
296
  # A database that never had its statistics gathered gets them now, before any report is read, rather
280
297
  # than at the first scheduled check, which an app may never run.
@@ -601,6 +618,10 @@ module Runlight
601
618
  if parsed.nil? || (parsed.protocol != "https:" && parsed.protocol != "http:")
602
619
  raise SettingsError.new("Enter the service's address, starting with https://", "assistant_address_bad")
603
620
  end
621
+ if Safefetch.metadata_host?(parsed.hostname)
622
+ raise SettingsError.new("#{parsed.hostname} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused",
623
+ { "host" => parsed.hostname })
624
+ end
604
625
  end
605
626
  raise SettingsError.new("Enter the service's address", "assistant_address") if base_url == "" && provider["baseUrl"] == ""
606
627
 
@@ -1335,6 +1356,8 @@ module Runlight
1335
1356
  @salts = {}
1336
1357
  sites.map { |site| site["timezone"] }.uniq.each { |timezone| current_salts(now, timezone) }
1337
1358
  drop_old_salts(now)
1359
+ # Sign-in counts that have run out, some keyed by an address, and the hashing keys of days gone.
1360
+ Accounts::Throttle.sweep(@store, now)
1338
1361
  # Every site's retention covers any one site's that is still waiting.
1339
1362
  @pruning = []
1340
1363
  begin