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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -1
- data/docs/architecture.md +7 -2
- data/docs/configuration.md +81 -0
- data/docs/internals/participants.md +23 -6
- data/docs/internals/remote-sessions.md +6 -2
- data/lib/samagotchi/bridge.rb +25 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/session_access/local.rb +46 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +231 -26
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +24 -0
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/participant.js +17 -3
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/server.rb +70 -10
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- metadata +7 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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[
|
|
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" =>
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
580
|
-
# features only
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|