samagotchi 0.108.0 → 0.109.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.
@@ -59,6 +59,7 @@ require_relative "principal"
59
59
  require_relative "participant_access"
60
60
  require_relative "participants_admin"
61
61
  require_relative "participant_review"
62
+ require_relative "proxy_gate"
62
63
  require_relative "../log"
63
64
  require_relative "../paths"
64
65
  require_relative "../redacted_inspect"
@@ -79,6 +80,7 @@ module Samagotchi
79
80
  include ParticipantAccess
80
81
  include ParticipantsAdmin
81
82
  include ParticipantReview
83
+ include ProxyGate
82
84
 
83
85
  DEFAULT_HOST = BridgeClient::HOST
84
86
  BRIDGE_WAIT_TIMEOUT = 10.0
@@ -141,6 +143,10 @@ module Samagotchi
141
143
  # loopback only, no token
142
144
  # @param grants [Grants, nil] the participants' grants (default: the
143
145
  # store next to +state_dir+, Grants.path)
146
+ # @param public_url [PublicUrl, nil] behind an auth proxy
147
+ # (web.public_url, ProxyGate): every request needs a token
148
+ # @param public_token [String, Token::Source, nil] the access token
149
+ # with +public_url+ (the LAN one when both are set)
144
150
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
145
151
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
146
152
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
@@ -148,7 +154,7 @@ module Samagotchi
148
154
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
149
155
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
150
156
  working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
151
- grants: nil)
157
+ grants: nil, public_url: nil, public_token: nil)
152
158
  @manager = manager || SessionManager
153
159
  @registry = registry
154
160
  @registry_given = !registry.nil?
@@ -173,17 +179,36 @@ module Samagotchi
173
179
  @refused_logged = {}
174
180
  @wait_poll_interval = wait_poll_interval
175
181
  init_participant_access(grants, state_dir)
182
+ init_proxy_gate(public_url, public_token)
176
183
  end
177
184
 
178
185
  def call(env)
179
186
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
180
- response = route(env)
187
+ response = unframed(route(env))
181
188
  log_request(env, response, started)
182
189
  response
183
190
  end
184
191
 
185
192
  private
186
193
 
194
+ # No page of chi web's may be framed by another (clickjacking): every
195
+ # HTML answer says so. The page's full CSP is a later change.
196
+ FRAME_HEADERS = { "X-Frame-Options" => "DENY", "Content-Security-Policy" => "frame-ancestors 'none'" }.freeze
197
+ FRAME_DESTS = %w[iframe frame embed object].freeze
198
+
199
+ def unframed(response)
200
+ status, headers, body = response
201
+ return response unless headers["Content-Type"].to_s.start_with?("text/html")
202
+
203
+ [status, headers.merge(FRAME_HEADERS), body]
204
+ end
205
+
206
+ # A frame/embed/object load from anything but the page's own origin.
207
+ def framed?(env)
208
+ FRAME_DESTS.include?(env["HTTP_SEC_FETCH_DEST"].to_s.downcase) &&
209
+ env["HTTP_SEC_FETCH_SITE"].to_s.downcase != "same-origin"
210
+ end
211
+
187
212
  # The routes, first match wins: a handler takes the request and the
188
213
  # path's captures (the session id, an image's name). The fourth column
189
214
  # is who may use the route (ROUTE_PERMISSIONS, checked against the
@@ -200,6 +225,7 @@ module Samagotchi
200
225
  ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
201
226
  ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
202
227
  ["GET", %r{\A/api/info\z}, :handle_info, :participant],
228
+ ["POST", %r{\A/api/token\z}, :handle_token, :participant],
203
229
  ["GET", %r{\A/api/models\z}, :handle_models, :owner],
204
230
  ["GET", %r{\A/api/history\z}, :handle_history, :owner],
205
231
  ["GET", %r{\A/api/events\z}, :handle_events, :owner],
@@ -251,6 +277,10 @@ module Samagotchi
251
277
  req = Rack::Request.new(env)
252
278
  return forbidden unless local_host_header?(env)
253
279
  return cross_origin if cross_site?(env)
280
+ return error_response(403, "framed", "chi web's pages can't be framed by another page") if framed?(env)
281
+ # The fragment trade's script, which the 401 page loads: no secret in it.
282
+ return serve_static(req) if req.get? && GATE_FREE_PATHS.include?(req.path_info)
283
+ return probe_info if probe_info?(req)
254
284
 
255
285
  principal, denied = token_gate(req)
256
286
  return denied if denied
@@ -328,8 +358,11 @@ module Samagotchi
328
358
  # A viewer on this machine, whose editor can open the session's
329
359
  # paths: a loopback peer, or this Mac on its own LAN address (the LAN
330
360
  # URL or its QR code opened here). The phone isn't. An SSH tunnel or
331
- # a local reverse proxy looks like loopback too (a known gap).
361
+ # a local reverse proxy looks like loopback too (a known gap), unless
362
+ # it leaves a trace and web.public_url is set (#proxied?).
332
363
  def local_viewer?(req)
364
+ return false if proxied?(req)
365
+
333
366
  peer = req.get_header("REMOTE_ADDR").to_s
334
367
  LOOPBACK_PEERS.include?(peer) || (!@lan.nil? && peer == @lan[:ip])
335
368
  end
@@ -342,10 +375,12 @@ module Samagotchi
342
375
  # check stops DNS-rebinding pages. It reads the raw Host header:
343
376
  # Rack's req.host trusts X-Forwarded-Host, which such a page could
344
377
  # set. A request with no Host (HTTP/1.0) is answered only for a
345
- # loopback peer.
378
+ # loopback peer. With web.public_url its host (with its port, exactly)
379
+ # is answered too.
346
380
  def local_host_header?(env)
347
381
  host = env["HTTP_HOST"].to_s
348
382
  return LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s) if host.empty?
383
+ return true if @public_url&.host?(host)
349
384
 
350
385
  name = host.downcase.sub(/:\d*\z/, "")
351
386
  LOOPBACK_NAMES.include?(name) || (!@lan.nil? && name == @lan[:ip])
@@ -359,10 +394,17 @@ module Samagotchi
359
394
  # the cross-site check already refuses what Strict would add.
360
395
  TOKEN_COOKIE_ATTRIBUTES = "HttpOnly; SameSite=Lax; Path=/; Max-Age=34560000"
361
396
  REFUSED_LOG_INTERVAL = 60.0
397
+ REFUSED_LOG_KEYS = 256
362
398
 
363
399
  # Where #route keeps the request's Principal for its handlers.
364
400
  PRINCIPAL_ENV = "samagotchi.principal"
365
401
  PAGE_PATHS = ["/", "/index.html"].freeze
402
+ # POST {code} or {token}: the page trades a link's one-time code (its
403
+ # #code= fragment, token_fragment.js) or a pasted token (the 401 page's
404
+ # form) for the cookie.
405
+ TOKEN_TRADE_PATH = "/api/token"
406
+ TRADE_BODY_LIMIT = 4096
407
+ GATE_FREE_PATHS = ["/token_fragment.js"].freeze
366
408
 
367
409
  # The request's Principal, as #route resolved it.
368
410
  def principal(req) = req.env.fetch(PRINCIPAL_ENV)
@@ -378,9 +420,16 @@ module Samagotchi
378
420
  def token_gate(req)
379
421
  principal = principal_for(req)
380
422
  return [nil, refused_token(req)] if principal.nil?
423
+
381
424
  # The cookie gets the token that was presented: the owner's, or the
382
425
  # participant's grant's.
383
- return [nil, token_redirect(req, req.env[GRANT_TOKEN_ENV] || current_token)] if token_trade?(req)
426
+ if token_trade?(req)
427
+ token = req.env[GRANT_TOKEN_ENV] || current_token
428
+ return [nil, token_redirect(req, token)] unless fragment_trade?(req)
429
+ return [nil, form_trade_redirect(req, token)] if form_post?(req)
430
+
431
+ return [nil, token_cookie_response(req, token)]
432
+ end
384
433
 
385
434
  [principal, nil]
386
435
  end
@@ -394,9 +443,12 @@ module Samagotchi
394
443
  # machine's loopback), else nil. An ended or unknown grant's token is
395
444
  # nil anywhere: it never falls back to the owner.
396
445
  # @return [Principal, nil]
446
+ # With web.public_url (ProxyGate) every request needs a token, and a
447
+ # proxied one is #proxied_principal's.
397
448
  def principal_for(req)
398
449
  given = presented_tokens(req)
399
- return Principal::OWNER if @lan && given.any? { |value| token_match?(value, current_token) }
450
+ return proxied_principal(req, given) if proxied?(req)
451
+ return Principal::OWNER if (@lan || @public_url) && owner_token?(given)
400
452
 
401
453
  grant_tokens = given.select { |value| Grants.token?(value) }
402
454
  return Principal::OWNER if grant_tokens.empty? && !token_required?(req)
@@ -404,19 +456,93 @@ module Samagotchi
404
456
  participant_principal(req, grant_tokens)
405
457
  end
406
458
 
459
+ # With web.public_url a request from this machine that didn't come
460
+ # through the proxy (curl, chi's own callers) presents the Bearer
461
+ # header only: no cookie, and no trade that would set one. A cookie for
462
+ # 127.0.0.1 goes to every server on any of its ports. A LAN peer
463
+ # (web.host lan) keeps the LAN's token link and cookie.
407
464
  def presented_tokens(req)
408
- token_trade?(req) ? [req.params["token"]] : [bearer_token(req), req.cookies[TOKEN_COOKIE]]
465
+ return [traded_token(req)] if token_trade?(req)
466
+ return [bearer_token(req)] if direct_public?(req)
467
+
468
+ [bearer_token(req), req.cookies[token_cookie_name(req)]]
469
+ end
470
+
471
+ def direct_public?(req)
472
+ !@public_url.nil? && LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) && !proxied?(req)
473
+ end
474
+
475
+ # The token a trade presents: the page's ?token=, or POST /api/token's
476
+ # body's (read once): its token, or the token a one-time link code
477
+ # stands for (LinkCodes#redeem: used up here, whatever the gate says).
478
+ def traded_token(req)
479
+ return req.params["token"] unless fragment_trade?(req)
480
+
481
+ req.env.fetch(TRADED_TOKEN_ENV) do
482
+ raw = request_body(req, TRADE_BODY_LIMIT).to_s
483
+ value = if raw.bytesize >= TRADE_BODY_LIMIT then nil
484
+ elsif form_post?(req) then Rack::Utils.parse_query(raw)
485
+ else parse_json(raw)
486
+ end
487
+ value = {} unless value.is_a?(Hash)
488
+ req.env[TRADED_TOKEN_ENV] = value.key?("code") ? code_token(value["code"]) : string_or_nil(value["token"])
489
+ end
490
+ end
491
+
492
+ def string_or_nil(value) = value.is_a?(String) ? value : nil
493
+
494
+ def code_token(code)
495
+ kind, token = @link_codes.redeem(code)
496
+ kind == :owner ? current_token : token
497
+ end
498
+
499
+ TRADED_TOKEN_ENV = "samagotchi.traded_token"
500
+
501
+ def fragment_trade?(req) = req.post? && req.path_info == TOKEN_TRADE_PATH
502
+
503
+ # The 401 page's paste box, posted as a form (no script needed).
504
+ def form_post?(req) = req.media_type == "application/x-www-form-urlencoded"
505
+
506
+ def owner_token?(given) = given.any? { |value| token_match?(value, current_token) }
507
+
508
+ # The cookie a request's token rides in, its name and attributes per
509
+ # request: a proxied request's is ProxyGate::SECURE_COOKIE, Secure (the
510
+ # public URL is https). A browser may hold both.
511
+ def token_cookie_name(req) = proxied?(req) ? SECURE_COOKIE : TOKEN_COOKIE
512
+
513
+ def token_cookie_attributes(req)
514
+ proxied?(req) ? "Secure; #{TOKEN_COOKIE_ATTRIBUTES}" : TOKEN_COOKIE_ATTRIBUTES
515
+ end
516
+
517
+ def token_cookie(req, value)
518
+ "#{token_cookie_name(req)}=#{value}; #{token_cookie_attributes(req)}"
409
519
  end
410
520
 
411
- def token_required?(req) = @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
521
+ def cleared_token_cookie(req)
522
+ "#{token_cookie_name(req)}=; #{token_cookie_attributes(req).sub(/Max-Age=\d+/, "Max-Age=0")}"
523
+ end
524
+
525
+ def token_required?(req)
526
+ return true if @public_url
527
+
528
+ @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
529
+ end
412
530
 
413
531
  # The page opened with ?token= (a gated request, or a grant's token
414
532
  # anywhere): its token is traded for the cookie, whatever else the
415
533
  # request carries.
534
+ # POST /api/token is the same trade for a token the page found in its
535
+ # URL's fragment.
416
536
  def token_trade?(req)
417
- return false unless req.get? && PAGE_PATHS.include?(req.path_info) && req.params.key?("token")
537
+ return false if direct_public?(req)
538
+
539
+ # Through the proxy no GET trade (a ?token= rides the sign-in
540
+ # redirect, and a crafted link would swap the owner's cookie): a
541
+ # link's #code= and the 401 page's POST do it.
542
+ page = req.get? && PAGE_PATHS.include?(req.path_info) && req.params.key?("token") && !(@public_url && proxied?(req))
543
+ return false unless page || fragment_trade?(req)
418
544
 
419
- token_required?(req) || Grants.token?(req.params["token"])
545
+ token_required?(req) || Grants.token?(traded_token(req))
420
546
  end
421
547
 
422
548
  # The 401 for a request #principal_for refused: an ended grant's, else
@@ -425,17 +551,16 @@ module Samagotchi
425
551
  # owner's, who opened a participant link) is the owner's again.
426
552
  def refused_token(req)
427
553
  response = grant_ended_response(req) || unauthorized(req)
428
- return response if token_required?(req) || !Grants.token?(req.cookies[TOKEN_COOKIE])
554
+ return response if token_required?(req) || !Grants.token?(req.cookies[token_cookie_name(req)])
429
555
 
430
556
  status, headers, body = response
431
- [status, headers.merge("Set-Cookie" => "#{TOKEN_COOKIE}=; #{TOKEN_COOKIE_ATTRIBUTES.sub(/Max-Age=\d+/, "Max-Age=0")}"),
432
- body]
557
+ [status, headers.merge("Set-Cookie" => cleared_token_cookie(req)), body]
433
558
  end
434
559
 
435
560
  def bearer_token(req) = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
436
561
 
437
562
  def current_token
438
- token = @lan[:token]
563
+ token = @lan ? @lan[:token] : @public_token
439
564
  token.respond_to?(:current) ? token.current : token
440
565
  end
441
566
 
@@ -446,32 +571,87 @@ module Samagotchi
446
571
  end
447
572
 
448
573
  # The same page (and its other parameters, as they were written), the
449
- # token traded for the cookie.
574
+ # token traded for the cookie. Proxied, the Location is absolute from
575
+ # web.public_url: WEBrick would make a relative one absolute from the
576
+ # first X-Forwarded-Host/-Proto, which a client sets through
577
+ # oauth2-proxy (an open redirect).
450
578
  def token_redirect(req, token)
451
579
  query = req.query_string.split("&").reject { |pair| pair.split("=", 2).first == "token" }.join("&")
452
580
  location = query.empty? ? req.path_info : "#{req.path_info}?#{query}"
453
- [303, { "Location" => location, "Set-Cookie" => "#{TOKEN_COOKIE}=#{token}; #{TOKEN_COOKIE_ATTRIBUTES}",
581
+ location = "#{@public_url.origin}#{location}" if proxied?(req)
582
+ [303, { "Location" => location, "Set-Cookie" => token_cookie(req, token),
454
583
  "Cache-Control" => "no-store", "Content-Length" => "0" }, []]
455
584
  end
456
585
 
586
+ # The paste box's trade: the cookie and a 303 to the page (absolute
587
+ # from web.public_url when proxied, as #token_redirect).
588
+ def form_trade_redirect(req, token)
589
+ location = proxied?(req) ? "#{@public_url.origin}/" : "/"
590
+ [303, { "Location" => location, "Set-Cookie" => token_cookie(req, token), "Cache-Control" => "no-store",
591
+ "Content-Length" => "0" }, []]
592
+ end
593
+
594
+ # The fragment trade's answer: the cookie, no body.
595
+ def token_cookie_response(req, token)
596
+ [204, { "Set-Cookie" => token_cookie(req, token), "Cache-Control" => "no-store" }, []]
597
+ end
598
+
599
+ # POST /api/token past the gate without a trade (nothing to trade here:
600
+ # no token is asked of this request): nothing to do.
601
+ def handle_token(_req) = [204, { "Cache-Control" => "no-store" }, []]
602
+
457
603
  def unauthorized(req)
458
- log_refused(req.get_header("REMOTE_ADDR").to_s, req.path_info)
459
- return error_response(401, "unauthorized", "chi web on the LAN needs its access token") if req.path_info.start_with?("/api/")
604
+ reason = req.env[REFUSED_ENV]
605
+ log_refused(refused_peer(req), req.path_info, reason)
606
+ return error_response(401, "unauthorized", unauthorized_detail(req, reason)) if req.path_info.start_with?("/api/")
460
607
 
461
- body = UNAUTHORIZED_PAGE
608
+ body = @public_url ? PUBLIC_UNAUTHORIZED_PAGE : UNAUTHORIZED_PAGE
462
609
  [401, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" },
463
610
  [body]]
464
611
  end
465
612
 
613
+ def unauthorized_detail(req, reason)
614
+ return "chi web behind the proxy: #{reason || "needs its access token"}" if proxied?(req)
615
+ return "chi web with web.public_url needs its access token, on this machine too" if @public_url
616
+
617
+ "chi web on the LAN needs its access token"
618
+ end
619
+
620
+ # Who a refusal is logged as: the peer, or behind a proxy (every peer
621
+ # 127.0.0.1) the client: its identity, else the X-Forwarded-For hop the
622
+ # proxy appended (the last one).
623
+ def refused_peer(req)
624
+ return req.get_header("REMOTE_ADDR").to_s unless proxied?(req)
625
+
626
+ identity = @public_url.identity_env && req.env[@public_url.identity_env]
627
+ hop = req.get_header("HTTP_X_FORWARDED_FOR").to_s.split(",").last.to_s.strip
628
+ "proxy:#{[identity.to_s.strip, hop].find { |v| !v.empty? } || "?"}"[0, 200]
629
+ end
630
+
466
631
  # At most once a minute per peer: a phone with an old link retries.
467
- def log_refused(peer, path)
632
+ # At most REFUSED_LOG_KEYS peers a minute (rotating headers can't
633
+ # fill the log).
634
+ def log_refused(peer, path, reason = nil)
468
635
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
469
636
  due = @refused_mutex.synchronize do
470
637
  @refused_logged.delete_if { |_, at| now - at >= REFUSED_LOG_INTERVAL } if @refused_logged.size > 64
638
+ next false if @refused_logged.size >= REFUSED_LOG_KEYS && !@refused_logged.key?(peer)
639
+
471
640
  last = @refused_logged[peer]
472
641
  (last.nil? || now - last >= REFUSED_LOG_INTERVAL).tap { |fresh| @refused_logged[peer] = now if fresh }
473
642
  end
474
- Log.warn(:web, "unauthorized", peer: peer, path: path) if due
643
+ Log.warn(:web, "unauthorized", peer: peer, path: path, **(reason ? { reason: reason } : {})) if due
644
+ end
645
+
646
+ # Behind a proxy (web.public_url) the paste box posts the token in the
647
+ # body (POST /api/token): a GET would put it in the URL, the proxy's
648
+ # access log and the history. On this machine the page opens through
649
+ # the proxy too.
650
+ def self.public_unauthorized_page(page)
651
+ page.sub('<form method="get" action="/">', '<form method="post" action="/api/token">')
652
+ .sub("Open the link chi web printed in its terminal (LAN, or Proxied behind an\nauth proxy), or scan its QR code.",
653
+ "Open the Proxied link chi web printed in its terminal (it works once; chi web again prints a new " \
654
+ "one), or paste the token from its web-token file.")
475
655
  end
476
656
 
477
657
  # What a phone sees without the token. The field is for a home-screen
@@ -489,14 +669,26 @@ module Samagotchi
489
669
  </style></head>
490
670
  <body>
491
671
  <h1>chi web</h1>
492
- <p>This page needs chi web's access token. Open the LAN link chi web printed in its terminal, or scan its QR code.</p>
672
+ <p>This page needs chi web's access token. Open the link chi web printed in its terminal (LAN, or Proxied behind an
673
+ auth proxy), or scan its QR code.</p>
674
+ <p id="trade" hidden></p>
493
675
  <form method="get" action="/">
494
676
  <label for="token">Or paste the token:</label>
495
677
  <input id="token" name="token" autocomplete="off" autocapitalize="off" spellcheck="false">
496
678
  <button type="submit">Open</button>
497
679
  </form>
680
+ <script type="module">
681
+ import { tradeFragmentCode } from "/token_fragment.js";
682
+ tradeFragmentCode({ onRefused: (words) => {
683
+ const line = document.getElementById("trade");
684
+ line.textContent = words;
685
+ line.hidden = false;
686
+ } });
687
+ </script>
498
688
  </body></html>
499
689
  HTML
690
+ PUBLIC_UNAUTHORIZED_PAGE = public_unauthorized_page(UNAUTHORIZED_PAGE).freeze
691
+ private_class_method :public_unauthorized_page
500
692
 
501
693
  # Another website's page in the desktop browser can send "simple"
502
694
  # requests here (a text/plain POST needs no preflight) and would start
@@ -510,18 +702,30 @@ module Samagotchi
510
702
  def cross_site?(env)
511
703
  foreign = %w[cross-site same-site].include?(env["HTTP_SEC_FETCH_SITE"].to_s.downcase)
512
704
  origin = env["HTTP_ORIGIN"]
513
- foreign ||= !origin.nil? && origin.downcase != "http://#{env["HTTP_HOST"].to_s.downcase}"
705
+ foreign ||= !origin.nil? && !same_origin?(env, origin.downcase)
514
706
  return false unless foreign
515
707
 
516
708
  !%w[GET HEAD].include?(env["REQUEST_METHOD"]) || env["PATH_INFO"].to_s.start_with?("/api/")
517
709
  end
518
710
 
711
+ # The page's own origin: through the proxy only web.public_url's
712
+ # (https), else http://<Host> (or the public origin).
713
+ def same_origin?(env, origin)
714
+ return origin == @public_url.origin if @public_url && proxied?(Rack::Request.new(env))
715
+
716
+ origin == "http://#{env["HTTP_HOST"].to_s.downcase}" || public_origin?(origin)
717
+ end
718
+
719
+ # The page served through web.public_url (Origin https://<public host>).
720
+ def public_origin?(origin) = !@public_url.nil? && origin.downcase == @public_url.origin
721
+
519
722
  def cross_origin
520
723
  error_response(403, "cross_origin", "requests from other websites are refused")
521
724
  end
522
725
 
523
726
  def forbidden
524
- names = @lan ? "127.0.0.1, [::1], localhost and #{@lan[:ip]}" : "127.0.0.1, [::1] and localhost"
727
+ names = ["127.0.0.1", "[::1]", "localhost", @lan && @lan[:ip], @public_url&.host].compact
728
+ names = "#{names[0..-2].join(", ")} and #{names.last}"
525
729
  error_response(403, "forbidden", "only the host names #{names} are answered")
526
730
  end
527
731
 
@@ -576,8 +780,9 @@ module Samagotchi
576
780
  # session_commands: the routes chi --remote needs
577
781
  # (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
578
782
  # attached TUI needs (ATTACH_FEATURE). lan: its LAN address, nil when it
579
- # listens on loopback only. A participant gets app, version and
580
- # features only (#participant_info).
783
+ # listens on loopback only; public_url: web.public_url's origin, nil
784
+ # without one. A participant gets app, version and features only
785
+ # (#participant_info).
581
786
  def handle_info(req)
582
787
  return participant_info unless principal(req).owner?
583
788
 
@@ -91,8 +91,10 @@ module Samagotchi
91
91
  # grant from before them, which takes the config's (#spend_cap,
92
92
  # #queue_limit).
93
93
  Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
94
- :revoked_by, :last_used_at, :cap, :queue) do
95
- def initialize(cap: nil, queue: nil, **) = super
94
+ :revoked_by, :last_used_at, :cap, :queue, :identity) do
95
+ # identity: the proxy identity (an email, lower case) the grant is
96
+ # bound to, nil for any (ProxyGate; issued from shared-sessions P6).
97
+ def initialize(cap: nil, queue: nil, identity: nil, **) = super
96
98
 
97
99
  def self.from_h(hash)
98
100
  new(**members.to_h { |key| [key, hash[key.to_s]] })
@@ -132,7 +134,8 @@ module Samagotchi
132
134
  state: state(now).to_s, cap: spend_cap, queue: queue_limit }
133
135
  end
134
136
 
135
- def to_store = to_h.transform_keys(&:to_s)
137
+ # A grant bound to no identity is stored as before identity existed.
138
+ def to_store = to_h.transform_keys(&:to_s).tap { |hash| hash.delete("identity") if identity.nil? }
136
139
 
137
140
  def inspect = "#<Grant #{id} #{session_id[0, 8]} #{name.inspect}>"
138
141
  alias_method :to_s, :inspect
@@ -6,7 +6,11 @@ require "net/http"
6
6
  module Samagotchi
7
7
  module Web
8
8
  # What answers GET /api/info on host:port: the one probe for a running
9
- # chi web (chi web's own port check, chi update, chi self).
9
+ # chi web (chi web's own port check, chi share's port, chi update, chi
10
+ # self). It never sends a token: whatever listens on the port may not be
11
+ # chi web (another user's process on a shared host). A chi web behind a
12
+ # proxy answers it without one (ProxyGate#probe_info). Nothing it
13
+ # answers is trusted for a link.
10
14
  module InfoProbe
11
15
  module_function
12
16
 
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require "time"
9
+ require_relative "../atomic_file"
10
+
11
+ module Samagotchi
12
+ module Web
13
+ # One-time link codes (web.public_url): what a link's fragment carries
14
+ # instead of a token (https://<public url>/#code=chc_…). A fragment
15
+ # outlives the proxy's sign-in redirects, so the sign-in page sits at
16
+ # that URL (its scripts can read it) and it lands in the browser's
17
+ # history: a code works once, then nothing. The page trades it at
18
+ # POST /api/token for the token's cookie.
19
+ #
20
+ # A code is the owner's (10 minutes) or a grant's (until the grant
21
+ # expires). Nothing secret is kept: the store has each code's digest,
22
+ # and a grant's token sealed (AES-256-GCM) under a key derived from the
23
+ # code itself, so only the code's holder can open it. The store is
24
+ # next to the grants (web-grants/link-codes.json, 0600 in that 0700
25
+ # folder), written under a lock: chi web and a local chi share both
26
+ # issue codes.
27
+ class LinkCodes
28
+ FILE = "link-codes.json"
29
+ LOCK = "link-codes.lock"
30
+ PREFIX = "chc_"
31
+ OWNER_TTL = 600
32
+ CODE_RE = /\Achc_[A-Za-z0-9_-]{43}\z/
33
+
34
+ # @param grants_path [String] the grants store's path (Grants#path)
35
+ def self.for_grants(grants_path, clock: -> { Time.now }) = new(File.join(File.dirname(grants_path), FILE), clock: clock)
36
+
37
+ def self.code?(value) = value.is_a?(String) && CODE_RE.match?(value)
38
+
39
+ attr_reader :path
40
+
41
+ def initialize(path, clock: -> { Time.now })
42
+ @path = path
43
+ @clock = clock
44
+ end
45
+
46
+ # @param kind [:owner, :grant]
47
+ # @param token [String, nil] the grant's token (sealed), nil for the owner
48
+ # @param ttl [Numeric] seconds
49
+ # @return [String] the code
50
+ def issue(kind:, ttl:, token: nil)
51
+ code = "#{PREFIX}#{SecureRandom.urlsafe_base64(32)}"
52
+ entry = { "digest" => digest(code), "kind" => kind.to_s, "expires_at" => (@clock.call + ttl).utc.iso8601 }
53
+ entry["sealed"] = seal(code, token) if token
54
+ update { |entries| entries << entry }
55
+ code
56
+ end
57
+
58
+ # The code's [kind, token] (token nil for the owner's), once: the code
59
+ # is gone after. nil for an unknown, used or expired code.
60
+ def redeem(code)
61
+ return nil unless LinkCodes.code?(code)
62
+
63
+ wanted = digest(code)
64
+ found = nil
65
+ update do |entries|
66
+ entries.reject do |entry|
67
+ next false unless same_digest?(entry["digest"].to_s, wanted)
68
+
69
+ found = entry
70
+ true
71
+ end
72
+ end
73
+ return nil unless found
74
+ return nil if Time.iso8601(found["expires_at"]) <= @clock.call
75
+
76
+ kind = found["kind"] == "grant" ? :grant : :owner
77
+ token = found["sealed"] && unseal(code, found["sealed"])
78
+ return nil if kind == :grant && token.nil?
79
+
80
+ [kind, token]
81
+ rescue ArgumentError
82
+ nil
83
+ end
84
+
85
+ private
86
+
87
+ def digest(code) = Digest::SHA256.hexdigest(code)
88
+
89
+ def same_digest?(left, right) = left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
90
+
91
+ def key(code) = OpenSSL::KDF.hkdf(code, salt: "chi-link-code", info: "seal", length: 32, hash: "SHA256")
92
+
93
+ def seal(code, token)
94
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
95
+ cipher.key = key(code)
96
+ iv = cipher.random_iv
97
+ cipher.auth_data = ""
98
+ sealed = cipher.update(token) + cipher.final
99
+ [iv + cipher.auth_tag + sealed].pack("m0")
100
+ end
101
+
102
+ def unseal(code, value)
103
+ raw = value.unpack1("m0")
104
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
105
+ cipher.key = key(code)
106
+ cipher.iv = raw[0, 12]
107
+ cipher.auth_tag = raw[12, 16]
108
+ cipher.auth_data = ""
109
+ cipher.update(raw[28..]) + cipher.final
110
+ rescue OpenSSL::Cipher::CipherError, ArgumentError
111
+ nil
112
+ end
113
+
114
+ # Read, change and write under the lock; expired codes go. The block
115
+ # gets the live codes and returns the codes to keep.
116
+ def update
117
+ dir = File.dirname(@path)
118
+ FileUtils.mkdir_p(dir, mode: 0o700)
119
+ File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
120
+ lock.flock(File::LOCK_EX)
121
+ now = @clock.call
122
+ stored = read
123
+ entries = yield(stored.reject { |entry| expired?(entry, now) })
124
+ # Written only when it changes: a guessed code costs no write.
125
+ next if entries == stored
126
+
127
+ AtomicFile.write(@path, "#{JSON.generate({ "version" => 1, "codes" => entries })}\n", perm: 0o600)
128
+ end
129
+ end
130
+
131
+ def expired?(entry, now)
132
+ Time.iso8601(entry["expires_at"].to_s) <= now
133
+ rescue ArgumentError
134
+ true
135
+ end
136
+
137
+ def read
138
+ data = JSON.parse(File.read(@path))
139
+ codes = data.is_a?(Hash) ? data["codes"] : nil
140
+ codes.is_a?(Array) ? codes.select { |entry| entry.is_a?(Hash) } : []
141
+ rescue Errno::ENOENT, JSON::ParserError
142
+ []
143
+ end
144
+ end
145
+ end
146
+ end
@@ -57,11 +57,18 @@ module Samagotchi
57
57
  # request's env for #token_gate's answer (#grant_ended_response). A
58
58
  # grant whose session was archived or is gone is revoked here (archive
59
59
  # and delete revoke too; this catches retention's prune and a delete
60
- # from another process).
60
+ # from another process). A grant bound to an identity (Grant#identity)
61
+ # is honoured only on a proxied request with that identity
62
+ # (ProxyGate): never on the LAN or this machine.
63
+ # @param identity [String, nil] the proxied request's identity
61
64
  # @return [Principal, nil]
62
- def participant_principal(req, tokens)
65
+ def participant_principal(req, tokens, identity: nil, proxied: false)
63
66
  tokens.each do |token|
64
67
  grant = @grants.lookup(token) or next
68
+ if grant.identity && !(proxied && identity == grant.identity)
69
+ req.env[ProxyGate::REFUSED_ENV] = "grant #{grant.id} is bound to another identity than #{identity || "(none)"}"
70
+ next
71
+ end
65
72
  grant = settle_grant(grant)
66
73
  state = grant.state(@grants.now)
67
74
  if state == :active
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  # paths; the owner's token is never shown.
40
40
  def participant_viewer(req, session)
41
41
  author = principal(req).owner? ? nil : principal(req).grant.author
42
- ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: [@lan && current_token].compact)
42
+ ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: [(@lan || @public_url) && current_token].compact)
43
43
  end
44
44
 
45
45
  # GET /api/sessions/:id/review: the participant page's data, always