karst 0.1.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 +7 -0
- data/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- metadata +138 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../value"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module Spec
|
|
7
|
+
# One RSpec example exercising one specific browser-facing controller/action
|
|
8
|
+
# request, built entirely from evidence already present in the JSON artifact
|
|
9
|
+
# Karst::Spec::Observer writes.
|
|
10
|
+
#
|
|
11
|
+
# `observed_status`/`observed_redirect` describe what this spec execution
|
|
12
|
+
# observed while it ran, never what the example asserted -- a failing or
|
|
13
|
+
# pending example still produces a Scenario, and `example_outcome` is how
|
|
14
|
+
# a consumer tells "verified" apart from "merely observed."
|
|
15
|
+
#
|
|
16
|
+
# An example that issues several browser-facing requests (a denied attempt
|
|
17
|
+
# followed by an allowed retry, a sign-in followed by the page it unlocks)
|
|
18
|
+
# legitimately produces one Scenario per such request: Karst never
|
|
19
|
+
# collapses an example down to "its last request," and never labels any
|
|
20
|
+
# request as authentication setup versus the subject under test.
|
|
21
|
+
#
|
|
22
|
+
# `principal_before`/`principal_after` are both kept, not collapsed to
|
|
23
|
+
# whichever was active when the request began: a signup or checkout
|
|
24
|
+
# scenario that establishes a session is exactly the case where the
|
|
25
|
+
# identity a request produces matters as much as the identity it started
|
|
26
|
+
# with, and either side alone would discard real evidence.
|
|
27
|
+
Scenario = Value.define(
|
|
28
|
+
:example_id,
|
|
29
|
+
:file_path,
|
|
30
|
+
:line_number,
|
|
31
|
+
:description_parts,
|
|
32
|
+
:full_description,
|
|
33
|
+
:karst_explicit,
|
|
34
|
+
:karst_name,
|
|
35
|
+
:example_outcome,
|
|
36
|
+
:controller,
|
|
37
|
+
:action,
|
|
38
|
+
:http_method,
|
|
39
|
+
:route_pattern,
|
|
40
|
+
:observed_path,
|
|
41
|
+
:observed_status,
|
|
42
|
+
:observed_redirect,
|
|
43
|
+
:principal_before,
|
|
44
|
+
:principal_after,
|
|
45
|
+
:principal_changed,
|
|
46
|
+
:sequence
|
|
47
|
+
) do
|
|
48
|
+
# The most specific zero-config name available without repeating the
|
|
49
|
+
# whole describe chain: RSpec's own nesting already puts the most
|
|
50
|
+
# specific description last. Never invents a persona the spec itself
|
|
51
|
+
# did not name.
|
|
52
|
+
def name
|
|
53
|
+
karst_name || description_parts.last || full_description
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def passed?
|
|
57
|
+
example_outcome == :passed
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def explicit?
|
|
61
|
+
karst_explicit
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/karst/value.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karst
|
|
4
|
+
# Small immutable value-object helper standing in for Ruby 3.2's
|
|
5
|
+
# Data.define, which Karst cannot rely on while supporting Ruby 2.7. Built
|
|
6
|
+
# on Struct(keyword_init: true), which already provides structural
|
|
7
|
+
# equality, keyword construction, and #members; the one thing Struct does
|
|
8
|
+
# not give for free is immutability, so .define freezes every instance its
|
|
9
|
+
# class produces. This matches Data's own contract exactly: the instance
|
|
10
|
+
# itself is frozen, but a member holding a mutable object (an Array, say)
|
|
11
|
+
# is not deep-frozen -- Data.define does not do that either, and Karst does
|
|
12
|
+
# not need it to.
|
|
13
|
+
module Value
|
|
14
|
+
# rubocop:disable Naming/BlockForwarding, Style/ArgumentsForwarding -- anonymous
|
|
15
|
+
# block forwarding (`&`) needs Ruby 3.1; this file runs on Ruby 2.7.
|
|
16
|
+
def self.define(*members, &block)
|
|
17
|
+
klass = Struct.new(*members, keyword_init: true, &block)
|
|
18
|
+
# rubocop:enable Naming/BlockForwarding, Style/ArgumentsForwarding
|
|
19
|
+
|
|
20
|
+
# Struct.new itself is a heavily overloaded class method (it doubles as
|
|
21
|
+
# the anonymous-subclass factory), so overriding it and calling super
|
|
22
|
+
# would climb straight past Struct's own keyword-init handling into
|
|
23
|
+
# that factory instead of a plain constructor. Overriding the ordinary
|
|
24
|
+
# instance method #initialize has no such trap.
|
|
25
|
+
klass.class_eval do
|
|
26
|
+
def initialize(...)
|
|
27
|
+
super
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
klass
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require_relative "../spec/catalog"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# Degrades to a countless badge rather than preventing the host response
|
|
10
|
+
# from being served, mirroring Panel's own posture toward a missing Catalog.
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module Karst
|
|
14
|
+
module Web
|
|
15
|
+
# Rewrites an eligible host HTML response to add a small, page-local link
|
|
16
|
+
# into Karst's route-scoped scenario catalog. Every check here exists to
|
|
17
|
+
# make injection safe to skip: Badge never guesses, never raises into the
|
|
18
|
+
# host application, and never touches a response it cannot confidently
|
|
19
|
+
# rewrite -- an untouched response is always the safe fallback.
|
|
20
|
+
#
|
|
21
|
+
# Route identity (controller/action/http_method) comes from
|
|
22
|
+
# Middleware, itself derived from a real process_action.action_controller
|
|
23
|
+
# notification; Badge never infers it from a path. `context.path` is
|
|
24
|
+
# carried along purely as display context for the panel (see
|
|
25
|
+
# Karst::Web::Panel), never as part of route identity.
|
|
26
|
+
module Badge
|
|
27
|
+
Context = Struct.new(:controller, :action, :http_method, :path, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# A generous ceiling, not a tuning knob: real pages are a few hundred KB
|
|
30
|
+
# at most, and this exists only to decline joining a body large enough
|
|
31
|
+
# that string-splicing it would be wasteful, not to police page weight.
|
|
32
|
+
MAX_INJECTABLE_BYTES = 5 * 1024 * 1024
|
|
33
|
+
private_constant :MAX_INJECTABLE_BYTES
|
|
34
|
+
|
|
35
|
+
BODY_CLOSE_TAG = %r{</body>}i
|
|
36
|
+
private_constant :BODY_CLOSE_TAG
|
|
37
|
+
|
|
38
|
+
# `all:initial` strips whatever the host page's own CSS would otherwise
|
|
39
|
+
# inherit onto the badge (fonts, colors, positioning contexts); every
|
|
40
|
+
# property Karst actually wants is re-declared afterward in the same
|
|
41
|
+
# attribute, so the cascade only ever sees Karst's own values.
|
|
42
|
+
BADGE_STYLE =
|
|
43
|
+
"all:initial;position:fixed;bottom:12px;right:12px;z-index:2147483647;" \
|
|
44
|
+
"display:inline-block;padding:.35rem .65rem;border-radius:999px;" \
|
|
45
|
+
"background:#202124;color:#fff;font:600 12px/1.4 -apple-system,system-ui,sans-serif;" \
|
|
46
|
+
"text-decoration:none;box-shadow:0 1px 4px rgba(0,0,0,.35)"
|
|
47
|
+
private_constant :BADGE_STYLE
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Returns a replacement [status, headers, body] triple, or nil when
|
|
51
|
+
# the response should be returned to the host application unchanged
|
|
52
|
+
# -- including when anything above raises, so a bug here can never
|
|
53
|
+
# turn into a broken host page.
|
|
54
|
+
def apply(status:, headers:, body:, context:)
|
|
55
|
+
return nil unless context
|
|
56
|
+
return nil unless html_response?(status: status, headers: headers)
|
|
57
|
+
# `to_ary` is Rack's own signal that a body is already fully
|
|
58
|
+
# buffered rather than genuinely streaming (Rack::ETag relies on
|
|
59
|
+
# exactly this same check for exactly this same reason). Rails
|
|
60
|
+
# delegates it down to the response's real stream object, so a
|
|
61
|
+
# Live-streaming response correctly reports false and is left
|
|
62
|
+
# alone. Under Rack 2 (Rails 7.0), ActionDispatch's older
|
|
63
|
+
# RackBody wrapper never exposes to_ary at all -- every response
|
|
64
|
+
# reports false there, so Badge never injects on that Rails
|
|
65
|
+
# series. That is a missing feature, not a bug: guessing
|
|
66
|
+
# bufferability by any other means risks blocking forever on a
|
|
67
|
+
# real streaming body, which this module will not do.
|
|
68
|
+
return nil unless body.respond_to?(:to_ary)
|
|
69
|
+
|
|
70
|
+
parts = body.to_ary
|
|
71
|
+
return nil unless bufferable?(parts)
|
|
72
|
+
|
|
73
|
+
rewrite(status: status, headers: headers, parts: parts, body: body, context: context)
|
|
74
|
+
rescue StandardError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def html_response?(status:, headers:)
|
|
81
|
+
return false unless status.is_a?(Integer) && status >= 200 && status < 300
|
|
82
|
+
return false if header(headers, "content-disposition")
|
|
83
|
+
return false if content_encoded?(headers)
|
|
84
|
+
|
|
85
|
+
header(headers, "content-type").to_s.downcase.start_with?("text/html")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def content_encoded?(headers)
|
|
89
|
+
encoding = header(headers, "content-encoding").to_s.strip
|
|
90
|
+
!encoding.empty? && !encoding.casecmp?("identity")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bufferable?(parts)
|
|
94
|
+
parts.is_a?(Array) && parts.all?(String) && parts.sum(&:bytesize) <= MAX_INJECTABLE_BYTES
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def rewrite(status:, headers:, parts:, body:, context:)
|
|
98
|
+
original = parts.join
|
|
99
|
+
index = original.rindex(BODY_CLOSE_TAG)
|
|
100
|
+
return nil unless index
|
|
101
|
+
|
|
102
|
+
html = "#{original[0...index]}#{markup(headers: headers, context: context)}#{original[index..]}"
|
|
103
|
+
new_headers = headers.dup
|
|
104
|
+
set_content_length!(new_headers, html.bytesize)
|
|
105
|
+
strip_validators!(new_headers)
|
|
106
|
+
body.close if body.respond_to?(:close)
|
|
107
|
+
[status, new_headers, [html]]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def markup(headers:, context:)
|
|
111
|
+
href = escape("/karst?#{query_for(context)}")
|
|
112
|
+
label = escape(label_for(context))
|
|
113
|
+
style = inline_style_allowed?(headers) ? " style=\"#{BADGE_STYLE}\"" : ""
|
|
114
|
+
"<a href=\"#{href}\"#{style}>#{label}</a>"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def query_for(context)
|
|
118
|
+
Rack::Utils.build_query(
|
|
119
|
+
"controller" => context.controller, "action" => context.action,
|
|
120
|
+
"method" => context.http_method, "path" => context.path
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def label_for(context)
|
|
125
|
+
catalog = load_catalog
|
|
126
|
+
return "Karst" unless catalog && catalog.status == :ready
|
|
127
|
+
|
|
128
|
+
count = catalog.scenarios_for(
|
|
129
|
+
controller: context.controller, action: context.action, http_method: context.http_method
|
|
130
|
+
).size
|
|
131
|
+
"Karst · #{count}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def load_catalog
|
|
135
|
+
return nil unless defined?(Karst::Spec::Catalog)
|
|
136
|
+
|
|
137
|
+
Karst::Spec::Catalog.load
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A CSP the host response itself declares is the only signal Badge
|
|
143
|
+
# trusts: Karst never rewrites that header, only reads it, and falls
|
|
144
|
+
# back to an unstyled link rather than risk a silently-dropped style
|
|
145
|
+
# under a policy with no 'unsafe-inline'.
|
|
146
|
+
def inline_style_allowed?(headers)
|
|
147
|
+
csp = header(headers, "content-security-policy").to_s.strip
|
|
148
|
+
return true if csp.empty?
|
|
149
|
+
|
|
150
|
+
directives = csp.split(";").map(&:strip)
|
|
151
|
+
directive = directives.find { |item| item.start_with?("style-src") } ||
|
|
152
|
+
directives.find { |item| item.start_with?("default-src") }
|
|
153
|
+
directive.nil? || directive.include?("'unsafe-inline'")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def header(headers, name)
|
|
157
|
+
key = headers.keys.find { |candidate| candidate.to_s.casecmp?(name) }
|
|
158
|
+
key && headers[key]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def set_content_length!(headers, bytesize)
|
|
162
|
+
key = headers.keys.find { |candidate| candidate.to_s.casecmp?("content-length") }
|
|
163
|
+
headers[key || "content-length"] = bytesize.to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ETag/Last-Modified validate a specific byte sequence; once Karst
|
|
167
|
+
# appends the badge, any validator computed on the host's original
|
|
168
|
+
# body no longer describes what is actually being served. Dropping
|
|
169
|
+
# them is safer than emitting a validator Karst never recomputed.
|
|
170
|
+
def strip_validators!(headers)
|
|
171
|
+
%w[etag last-modified].each do |name|
|
|
172
|
+
key = headers.keys.find { |candidate| candidate.to_s.casecmp?(name) }
|
|
173
|
+
headers.delete(key) if key
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def escape(value)
|
|
178
|
+
CGI.escapeHTML(value.to_s)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "active_support/security_utils"
|
|
6
|
+
|
|
7
|
+
module Karst
|
|
8
|
+
module Web
|
|
9
|
+
# State-changing browser identity operations and their same-session CSRF
|
|
10
|
+
# token. Rails controller CSRF is unavailable because /karst is served at
|
|
11
|
+
# the Rack boundary, before Action Controller dispatch.
|
|
12
|
+
class BrowserIdentity
|
|
13
|
+
TOKEN_KEY = "karst.csrf_token"
|
|
14
|
+
ACTIVE_KEY = "karst.browser_identity_active"
|
|
15
|
+
|
|
16
|
+
# The exact Devise/Warden scope the currently assumed identity was
|
|
17
|
+
# established under (see Identity.assume_browser), retained for the
|
|
18
|
+
# lifetime of the browser session so #clear can hand it straight back
|
|
19
|
+
# to Identity.clear_browser instead of that having to guess which of
|
|
20
|
+
# several selected sources produced the principal being cleared.
|
|
21
|
+
SCOPE_KEY = "karst.browser_identity_scope"
|
|
22
|
+
|
|
23
|
+
def initialize(request)
|
|
24
|
+
@request = request
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def token
|
|
28
|
+
session[TOKEN_KEY] ||= SecureRandom.hex(32)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def active?
|
|
32
|
+
session[ACTIVE_KEY] == true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def assume(params)
|
|
36
|
+
verify_token!(params["csrf_token"])
|
|
37
|
+
target = return_path(params["path"])
|
|
38
|
+
principal = Identity.resolve(model_name: params["principal_type"], id: params["principal_id"])
|
|
39
|
+
raise Identity::Unavailable, "principal is not in the configured source" unless principal
|
|
40
|
+
|
|
41
|
+
scope = Identity.assume_browser(@request, principal)
|
|
42
|
+
# Authentication hooks may clear or replace the host session. Rebuild
|
|
43
|
+
# Karst's control state only after that transition, and invalidate the
|
|
44
|
+
# token which authorized it rather than carrying pre-assumption state
|
|
45
|
+
# into the assumed identity.
|
|
46
|
+
session[ACTIVE_KEY] = true
|
|
47
|
+
session[SCOPE_KEY] = scope&.to_s
|
|
48
|
+
rotate_token!
|
|
49
|
+
target
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear(params)
|
|
53
|
+
verify_token!(params["csrf_token"])
|
|
54
|
+
target = return_path(params["path"])
|
|
55
|
+
scope = session[SCOPE_KEY]
|
|
56
|
+
Identity.clear_browser(@request, scope: scope&.to_sym)
|
|
57
|
+
session.delete(ACTIVE_KEY)
|
|
58
|
+
session.delete(SCOPE_KEY)
|
|
59
|
+
target
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def session
|
|
65
|
+
@request.session
|
|
66
|
+
rescue StandardError
|
|
67
|
+
raise Identity::Unavailable, "a writable Rack session is required"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def verify_token!(submitted)
|
|
71
|
+
expected = session[TOKEN_KEY]
|
|
72
|
+
valid = expected && submitted && expected.bytesize == submitted.bytesize &&
|
|
73
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, submitted)
|
|
74
|
+
raise Identity::Unavailable, "invalid Karst CSRF token" unless valid
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rotate_token!
|
|
78
|
+
session[TOKEN_KEY] = SecureRandom.hex(32)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A blank path is not a caller error: the panel renders this same
|
|
82
|
+
# hidden field for "Stop testing as" from a plain /karst visit with no
|
|
83
|
+
# ?path= in the query string (bookmarked, or reached without a route
|
|
84
|
+
# already selected) -- exactly what happens right after Test As
|
|
85
|
+
# navigates the browser away to the tested page and a developer comes
|
|
86
|
+
# back to /karst directly. Falling back to /karst itself keeps that
|
|
87
|
+
# button working instead of raising and leaving the assumed identity
|
|
88
|
+
# active.
|
|
89
|
+
def return_path(value)
|
|
90
|
+
return "/karst" if value.to_s.empty?
|
|
91
|
+
|
|
92
|
+
raw = value.to_s.split("?", 2).first
|
|
93
|
+
uri = URI.parse(raw)
|
|
94
|
+
valid = uri.relative? && raw.start_with?("/") && !raw.start_with?("//")
|
|
95
|
+
raise Identity::Unavailable, "return path must be a local application path" unless valid
|
|
96
|
+
|
|
97
|
+
raw
|
|
98
|
+
rescue URI::InvalidURIError
|
|
99
|
+
raise Identity::Unavailable, "return path must be a valid local application path"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module Web
|
|
7
|
+
# Determines whether a Rack peer is local without consulting proxy headers.
|
|
8
|
+
# WSL's NAT makes a Windows browser appear as the Linux VM's default gateway,
|
|
9
|
+
# so that one address is accepted when the process is verifiably running in
|
|
10
|
+
# WSL. Other private-network peers remain untrusted.
|
|
11
|
+
class Locality
|
|
12
|
+
LOOPBACK_RANGES = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")].freeze
|
|
13
|
+
private_constant :LOOPBACK_RANGES
|
|
14
|
+
|
|
15
|
+
def initialize(environment: ENV, osrelease_path: "/proc/sys/kernel/osrelease", route_path: "/proc/net/route")
|
|
16
|
+
@environment = environment
|
|
17
|
+
@osrelease_path = osrelease_path
|
|
18
|
+
@route_path = route_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def local?(remote_address)
|
|
22
|
+
address = IPAddr.new(remote_address.to_s)
|
|
23
|
+
loopback?(address) || wsl_gateway == address
|
|
24
|
+
rescue IPAddr::Error
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def loopback?(address)
|
|
31
|
+
LOOPBACK_RANGES.any? { |range| range.include?(address) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def wsl_gateway
|
|
35
|
+
return unless wsl?
|
|
36
|
+
|
|
37
|
+
gateway_address(default_gateway_hex)
|
|
38
|
+
rescue Errno::ENOENT, Errno::EACCES, IPAddr::Error
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def default_gateway_hex
|
|
43
|
+
route = File.foreach(@route_path).drop(1).find do |line|
|
|
44
|
+
fields = line.split
|
|
45
|
+
fields[1] == "00000000" && fields[3].to_i(16).anybits?(0x2)
|
|
46
|
+
end
|
|
47
|
+
route&.split&.fetch(2, nil)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def gateway_address(hex)
|
|
51
|
+
return unless hex&.match?(/\A[0-9A-Fa-f]{8}\z/)
|
|
52
|
+
|
|
53
|
+
IPAddr.new([hex].pack("H*").reverse.unpack("C4").join("."))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wsl?
|
|
57
|
+
@environment.key?("WSL_INTEROP") || @environment.key?("WSL_DISTRO_NAME") ||
|
|
58
|
+
File.read(@osrelease_path).match?(/microsoft|wsl/i)
|
|
59
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|