woods 1.6.2 → 1.6.4

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- require_relative '../util/host_guard'
5
+ require_relative 'origin_policy'
6
6
 
7
7
  module Woods
8
8
  module MCP
@@ -21,17 +21,12 @@ module Woods
21
21
  # also requiring Host to appear in the allow-list (or to be a loopback
22
22
  # address), we close that gap even when Rails is bound to 0.0.0.0.
23
23
  #
24
- # Port-matching: an allow-list entry WITHOUT a port (`http://localhost`)
25
- # matches that host on any port. An entry WITH a port (`http://localhost:3000`)
26
- # requires an exact port match. Specify explicit ports when port isolation
27
- # matters.
24
+ # Cross-origin entries match exact origins, with equivalent default ports.
25
+ # Portless entries also permit same-authority requests on other ports.
28
26
  #
29
27
  # Also answers CORS preflight (OPTIONS) with the matching allow-list.
30
28
  class OriginGuard
31
- DEFAULT_ALLOWED = %w[
32
- http://localhost http://127.0.0.1 http://[::1]
33
- https://localhost https://127.0.0.1 https://[::1]
34
- ].freeze
29
+ DEFAULT_ALLOWED = OriginPolicy::DEFAULT_ORIGINS
35
30
 
36
31
  # Hosts that always pass the Host-header check even without an explicit
37
32
  # allow-list entry — they resolve to loopback by definition and cannot
@@ -39,7 +34,7 @@ module Woods
39
34
  LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
40
35
 
41
36
  ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS'
42
- ALLOWED_HEADERS = 'Authorization, Content-Type, Mcp-Session-Id'
37
+ ALLOWED_HEADERS = 'Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id'
43
38
 
44
39
  # Response bodies are emitted as constants so the rejected Origin /
45
40
  # Host value is NEVER echoed back to the caller — preventing a
@@ -48,65 +43,44 @@ module Woods
48
43
  FORBIDDEN_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Origin not allowed' }, id: nil }.to_json.freeze
49
44
  FORBIDDEN_HOST_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Host not allowed' }, id: nil }.to_json.freeze
50
45
 
51
- def initialize(app, allowed_origins: nil)
46
+ attr_reader :policy
47
+
48
+ # Rails 6 passes middleware keyword options as a positional Hash on Ruby 3.
49
+ def initialize(app, options = {}, **keywords)
50
+ raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
51
+
52
+ initialize_options(app, **options, **keywords)
53
+ end
54
+
55
+ def initialize_options(app, allowed_origins: nil, policy: nil, path: nil)
52
56
  @app = app
53
- @allowed = Array(allowed_origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
54
- @allowed = DEFAULT_ALLOWED.dup if @allowed.empty?
55
- @allowed_hosts = @allowed.map { |o| extract_host(o) }.compact.uniq
57
+ @path = path
58
+ origins = allowed_origins.respond_to?(:call) ? allowed_origins.call : allowed_origins
59
+ @policy = policy || OriginPolicy.new(allowed_origins: origins)
56
60
  end
61
+ private :initialize_options
57
62
 
58
63
  def call(env)
64
+ return @app.call(env) if @path && !env['PATH_INFO'].to_s.start_with?(@path)
65
+
59
66
  origin = env['HTTP_ORIGIN']
60
67
  method = env['REQUEST_METHOD']
61
68
  host = env['HTTP_HOST']
62
69
 
63
- return forbidden if origin && !origin_allowed?(origin)
64
- return forbidden_host if host && !host_allowed?(host)
70
+ return forbidden unless policy.origin_allowed?(origin, host: host)
71
+ return forbidden_host unless policy.host_allowed?(host)
65
72
 
66
73
  return preflight(origin) if method == 'OPTIONS'
67
74
 
68
75
  status, headers, body = @app.call(env)
69
- headers = cors_headers(origin).merge(headers) if origin && origin_allowed?(origin)
76
+ headers = cors_headers(origin).merge(headers) if origin
70
77
  [status, headers, body]
71
78
  end
72
79
 
73
80
  private
74
81
 
75
- def normalize(origin)
76
- origin.to_s.sub(%r{/\z}, '').downcase
77
- end
78
-
79
- def extract_host(origin)
80
- host = origin.to_s.sub(%r{\Ahttps?://}, '').sub(%r{/.*\z}, '').downcase
81
- host.empty? ? nil : host
82
- end
83
-
84
- def host_allowed?(host)
85
- # Canonicalize (strip port, trailing dot, IPv6 brackets) via the
86
- # shared helper so Qdrant and OriginGuard stay in sync on bypass
87
- # notations. `normalized` keeps the port for literal allow-list
88
- # lookups; `bare` drops it for loopback matching.
89
- normalized = host.to_s.downcase.sub(/\.\z/, '')
90
- bare = Util::HostGuard.canonicalize(host)
91
-
92
- # Reject non-canonical numeric hosts. Net::HTTP / getaddrinfo
93
- # would happily resolve `0x7f000001` or `2130706433` to 127.0.0.1,
94
- # bypassing the loopback allow-list.
95
- return false if Util::HostGuard.suspicious_numeric_host?(bare)
96
-
97
- return true if LOOPBACK_HOSTS.include?(bare)
98
-
99
- @allowed_hosts.include?(normalized) || @allowed_hosts.include?(bare)
100
- end
101
-
102
- def origin_allowed?(origin)
103
- return false if origin.match?(/[[:cntrl:]]/)
104
-
105
- @allowed.include?(normalize(origin)) || @allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
106
- end
107
-
108
82
  def preflight(origin)
109
- headers = origin && origin_allowed?(origin) ? cors_headers(origin) : {}
83
+ headers = origin ? cors_headers(origin) : {}
110
84
  [204, headers, []]
111
85
  end
112
86
 
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../util/host_guard'
5
+
6
+ module Woods
7
+ module MCP
8
+ # One immutable policy for the outer Rack guard and the legacy MCP SDK.
9
+ # Keep SDK rebinding protection active and pass original request headers.
10
+ class OriginPolicy
11
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
12
+ DEFAULT_ORIGINS = %w[
13
+ http://localhost http://127.0.0.1 http://[::1]
14
+ https://localhost https://127.0.0.1 https://[::1]
15
+ ].freeze
16
+
17
+ attr_reader :transport_options
18
+
19
+ def initialize(allowed_origins: nil)
20
+ entries = Array(allowed_origins).compact.reject do |entry|
21
+ entry.to_s.valid_encoding? && entry.to_s.strip.empty?
22
+ end
23
+ @explicit = entries.map { |entry| configured_origin(entry) }.uniq.freeze
24
+ @allowed = @explicit.empty? ? DEFAULT_ORIGINS : @explicit
25
+ @hosts = @explicit.map do |origin|
26
+ host = authority(origin)
27
+ (host.end_with?(']') ? host[1...-1] : host).freeze
28
+ end.uniq.freeze
29
+ @transport_options = if @explicit.empty?
30
+ {}.freeze
31
+ else
32
+ { allowed_origins: transport_origins, allowed_hosts: @hosts }.freeze
33
+ end
34
+ freeze
35
+ end
36
+
37
+ def host_allowed?(host)
38
+ return true if host.nil?
39
+ return false unless host.is_a?(String) && host.valid_encoding?
40
+
41
+ parsed = parsed_origin("http://#{host}")
42
+ return false unless parsed
43
+
44
+ hostname = parsed.hostname.downcase
45
+ return false if Util::HostGuard.suspicious_numeric_host?(hostname)
46
+ return true if LOOPBACK_HOSTS.include?(hostname)
47
+
48
+ @hosts.include?(host.downcase) || @hosts.include?(hostname)
49
+ end
50
+
51
+ def origin_allowed?(origin, host:)
52
+ return true if origin.nil?
53
+ return false unless parsed_origin(origin)
54
+
55
+ normalized = normalized_origin(origin)
56
+ return true if @explicit.include?(normalized)
57
+ return false unless @allowed.include?(normalized) || @allowed.include?(normalized.sub(/:\d+\z/, ''))
58
+ return false unless host.is_a?(String) && host.valid_encoding?
59
+
60
+ default_port = normalized.start_with?('https://') ? ':443' : ':80'
61
+ authority(normalized).delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
62
+ end
63
+
64
+ private
65
+
66
+ def configured_origin(entry)
67
+ raw = entry.to_s
68
+ value = raw.strip.downcase.delete_suffix('/') if raw.valid_encoding?
69
+ unless parsed_origin(value)
70
+ label = raw.b.byteslice(0, 160).inspect
71
+ raise ArgumentError, "Invalid MCP allowed origin #{label}: expected http(s)://host[:port] without a path"
72
+ end
73
+
74
+ normalized_origin(value).freeze
75
+ end
76
+
77
+ def normalized_origin(origin)
78
+ value = origin.downcase
79
+ value.delete_suffix(value.start_with?('https://') ? ':443' : ':80')
80
+ end
81
+
82
+ # SDK 0.23 and 0.25 compare explicit entries literally. Both default-port
83
+ # spellings are needed for cross-origin requests; same-authority matching
84
+ # is otherwise performed independently by both layers.
85
+ def transport_origins
86
+ @explicit.flat_map do |origin|
87
+ parsed = parsed_origin(origin)
88
+ port = parsed.scheme == 'https' ? 443 : 80
89
+ parsed.port == port ? [origin, "#{origin}:#{port}"] : [origin]
90
+ end.uniq.map(&:freeze).freeze
91
+ end
92
+
93
+ def authority(origin)
94
+ origin.sub(%r{\Ahttps?://}, '')
95
+ end
96
+
97
+ def parsed_origin(origin)
98
+ return unless origin.is_a?(String) && origin.valid_encoding? && origin.ascii_only?
99
+ return if origin.match?(/[[:space:][:cntrl:]]/)
100
+
101
+ parsed = URI.parse(origin)
102
+ return unless %w[http https].include?(parsed.scheme&.downcase)
103
+ return unless parsed.host && !parsed.host.empty?
104
+ return unless parsed.path.to_s.empty? && !parsed.userinfo && !parsed.query && !parsed.fragment
105
+ return unless parsed.port&.between?(1, 65_535)
106
+
107
+ parsed
108
+ rescue URI::InvalidURIError, ArgumentError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/woods/railtie.rb CHANGED
@@ -34,7 +34,7 @@ module Woods
34
34
  )
35
35
  end
36
36
 
37
- initializer 'woods.console_mcp' do |app|
37
+ initializer 'woods.console_mcp' do |app| # rubocop:disable Metrics/BlockLength -- validates and scopes the legacy HTTP middleware stack
38
38
  config = Woods.configuration
39
39
  next unless config.console_mcp_enabled
40
40
 
@@ -63,15 +63,22 @@ module Woods
63
63
  next
64
64
  end
65
65
 
66
+ if token.to_s.length < Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH
67
+ raise Woods::ConfigurationError, '[Woods Console] console_mcp_token must be at least 32 characters.'
68
+ end
69
+
66
70
  # Origin guard first — rejects cross-origin POSTs before any auth cost.
67
71
  # BearerAuth next — requires `Authorization: Bearer <token>` on every request.
68
- app.middleware.use(Woods::MCP::OriginGuard, allowed_origins: Array(config.console_mcp_allowed_origins))
69
- app.middleware.use(Woods::MCP::BearerAuth, token: token)
72
+ origin_policy = Woods::MCP::OriginPolicy.new(allowed_origins: config.console_mcp_allowed_origins)
73
+ app.middleware.use(Woods::MCP::OriginGuard, policy: origin_policy, path: config.console_mcp_path)
74
+ app.middleware.use(Woods::MCP::BearerAuth,
75
+ token: -> { Woods.configuration.console_mcp_token }, path: config.console_mcp_path)
70
76
 
71
77
  app.middleware.use(
72
78
  Woods::Console::RackMiddleware,
73
79
  path: config.console_mcp_path,
74
- embedded_read_tools: config.console_embedded_read_tools
80
+ embedded_read_tools: config.console_embedded_read_tools,
81
+ origin_policy: origin_policy
75
82
  )
76
83
  end
77
84
  end
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.6.2'
4
+ VERSION = '1.6.4'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: woods
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.2
4
+ version: 1.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-18 00:00:00.000000000 Z
11
+ date: 2026-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp
@@ -152,6 +152,7 @@ files:
152
152
  - lib/woods/cache/solid_cache_store.rb
153
153
  - lib/woods/chunking/chunk.rb
154
154
  - lib/woods/chunking/semantic_chunker.rb
155
+ - lib/woods/console/adapter_family.rb
155
156
  - lib/woods/console/adapters/cache_adapter.rb
156
157
  - lib/woods/console/adapters/good_job_adapter.rb
157
158
  - lib/woods/console/adapters/job_adapter.rb
@@ -178,8 +179,10 @@ files:
178
179
  - lib/woods/console/scope_predicate_parser.rb
179
180
  - lib/woods/console/server.rb
180
181
  - lib/woods/console/sql_noise_stripper.rb
182
+ - lib/woods/console/sql_output_policy.rb
181
183
  - lib/woods/console/sql_table_scanner.rb
182
184
  - lib/woods/console/sql_validator.rb
185
+ - lib/woods/console/sqlite_read_guard.rb
183
186
  - lib/woods/console/table_gate.rb
184
187
  - lib/woods/console/tool_specs.rb
185
188
  - lib/woods/console/tools/tier1.rb
@@ -280,6 +283,7 @@ files:
280
283
  - lib/woods/mcp/http_transport_options.rb
281
284
  - lib/woods/mcp/index_reader.rb
282
285
  - lib/woods/mcp/origin_guard.rb
286
+ - lib/woods/mcp/origin_policy.rb
283
287
  - lib/woods/mcp/provider_probe.rb
284
288
  - lib/woods/mcp/renderers/claude_renderer.rb
285
289
  - lib/woods/mcp/renderers/json_renderer.rb
@@ -359,10 +363,10 @@ licenses:
359
363
  - MIT
360
364
  metadata:
361
365
  homepage_uri: https://github.com/lost-in-the/woods
362
- source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.2
363
- changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.2/CHANGELOG.md
366
+ source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.4
367
+ changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.4/CHANGELOG.md
364
368
  bug_tracker_uri: https://github.com/lost-in-the/woods/issues
365
- documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.2/docs
369
+ documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.4/docs
366
370
  rubygems_mfa_required: 'true'
367
371
  post_install_message:
368
372
  rdoc_options: []