woods 1.6.3 → 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.
@@ -38,20 +38,20 @@ module Woods
38
38
  # identifier so it does not hide the table name. ANSI-89 comma joins
39
39
  # are handled separately — see FROM_CLAUSE.
40
40
  JOIN_REFERENCE = /
41
- \b(?:STRAIGHT_)?JOIN\s+
42
- (?:ONLY\s+)?
41
+ \b(?:STRAIGHT_)?JOIN(?=[\s("`])\s*
42
+ (?:ONLY(?=[\s("`])\s*\(?\s*)?
43
43
  (?:
44
44
  (?:
45
- `(?<jschema_bt>[^`]+)` |
46
- "(?<jschema_dq>[^"]+)" |
47
- (?<jschema_bare>\w+)
45
+ `(?<jschema_bt>(?:``|[^`])+)` |
46
+ "(?<jschema_dq>(?:""|[^"])+)" |
47
+ (?<jschema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
48
48
  )
49
- \.
49
+ \s* \. \s*
50
50
  )?
51
51
  (?:
52
- `(?<backtick>[^`]+)` |
53
- "(?<double>[^"]+)" |
54
- (?<bare>\w+(?:\.\w+)?)
52
+ `(?<backtick>(?:``|[^`])+)` |
53
+ "(?<double>(?:""|[^"])+)" |
54
+ (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
55
55
  )
56
56
  /xi
57
57
 
@@ -66,7 +66,7 @@ module Woods
66
66
  # every `FROM` as its own independent scan match is what keeps CTEs,
67
67
  # UNIONs, and nested subqueries in coverage.
68
68
  FROM_CLAUSE = /
69
- \bFROM\s+
69
+ \bFROM(?=[\s("`])\s*
70
70
  (?<clause>.+?)
71
71
  (?=
72
72
  \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|
@@ -85,16 +85,16 @@ module Woods
85
85
  \A
86
86
  (?:
87
87
  (?:
88
- `(?<schema_bt>[^`]+)` |
89
- "(?<schema_dq>[^"]+)" |
90
- (?<schema_bare>\w+)
88
+ `(?<schema_bt>(?:``|[^`])+)` |
89
+ "(?<schema_dq>(?:""|[^"])+)" |
90
+ (?<schema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
91
91
  )
92
- \.
92
+ \s* \. \s*
93
93
  )?
94
94
  (?:
95
- `(?<backtick>[^`]+)` |
96
- "(?<double>[^"]+)" |
97
- (?<bare>\w+(?:\.\w+)?)
95
+ `(?<backtick>(?:``|[^`])+)` |
96
+ "(?<double>(?:""|[^"])+)" |
97
+ (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
98
98
  )
99
99
  /xi
100
100
 
@@ -102,42 +102,79 @@ module Woods
102
102
  # identifier. Strip it so the lead-identifier regex sees the table
103
103
  # directly. Anchored with `\A` because callers strip leading whitespace
104
104
  # first via #strip.
105
- ONLY_PREFIX = /\AONLY\s+/i
105
+ ONLY_PREFIX = /\AONLY(?=[\s("`])\s*/i
106
+
107
+ # Matches a MySQL executable comment (`/*!...*/` or the version-guarded
108
+ # `/*!NNNNN...*/`), capturing the body. Mirrors
109
+ # SqlValidator::EXECUTABLE_COMMENT_PATTERN — {SqlNoiseStripper}
110
+ # deliberately leaves these markers in place because their meaning is
111
+ # version-dependent; .executable_comment_views scans both of their
112
+ # possible semantics.
113
+ EXECUTABLE_COMMENT_PATTERN = %r{/\*(?:M)?!(?:\d{5,6})?(.*?)\*/}m
114
+
115
+ # Matches the standalone SQL `TABLE name` statement (PostgreSQL, and
116
+ # MySQL 8.0.19+) — shorthand for `SELECT * FROM name`. It appears as a
117
+ # full statement, inside a CTE body (`WITH x AS (TABLE blocked) ...`),
118
+ # or as a FROM-clause subquery (`FROM (TABLE blocked) AS t`), so it is
119
+ # scanned independently of FROM_CLAUSE/JOIN_REFERENCE rather than as
120
+ # part of either. The identifier grammar mirrors LEAD_IDENT.
121
+ TABLE_STATEMENT = /
122
+ \bTABLE(?=[\s("`])\s*
123
+ (?:ONLY(?=[\s("`])\s*\(?\s*)?
124
+ (?:
125
+ (?:
126
+ `(?<schema_bt>(?:``|[^`])+)` |
127
+ "(?<schema_dq>(?:""|[^"])+)" |
128
+ (?<schema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
129
+ )
130
+ \s* \. \s*
131
+ )?
132
+ (?:
133
+ `(?<backtick>(?:``|[^`])+)` |
134
+ "(?<double>(?:""|[^"])+)" |
135
+ (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
136
+ )
137
+ /xi
106
138
 
107
139
  # Returns every table/schema-qualified identifier referenced in the SQL
108
140
  # string. Noise (comments, string literals, dollar-quoted bodies) is
109
141
  # stripped before scanning. Both JOIN-style and ANSI-89 comma-join syntax
110
142
  # are handled.
111
143
  #
112
- # Literals are stripped under BOTH supported dialects and the scans
113
- # unioned. This scanner backs TableGate, so it may over-detect but must
114
- # never under-detect: stripping with the wrong dialect's escape rules
115
- # can swallow a real FROM clause — e.g. MySQL's `\'` escape applied on
116
- # a PostgreSQL host (where backslash is literal under
117
- # standard_conforming_strings) folds `'x\' FROM blocked WHERE y = '`
118
- # into one literal, hiding `blocked` from the gate while PostgreSQL
119
- # genuinely reads that table.
144
+ # A known adapter restricts the dialect, but not its session quote modes.
145
+ # Without known session settings, MySQL scans every quote-mode combination. An unknown
146
+ # adapter also scans PostgreSQL. This may over-detect, but must not hide a
147
+ # table merely because the server uses a different quote interpretation.
148
+ #
149
+ # MySQL executable comments (`/*! ... */`) are scanned under both of
150
+ # their possible semantics (see .executable_comment_views) so a table
151
+ # hidden at FROM/JOIN/subquery lead position is still surfaced.
120
152
  #
121
153
  # @param sql [String, nil] the SQL string to scan
122
154
  # @return [Array<String>] identifiers in first-encounter order, deduplicated
123
- def self.identifiers_in(sql, dialect: nil)
155
+ def self.identifiers_in(sql, dialect: nil, mysql_modes: nil)
124
156
  return [] if sql.nil? || sql.empty?
125
157
 
126
158
  results = []
127
159
  (dialect ? [dialect] : %i[postgres mysql]).each do |dialect|
128
- stripped = strip_noise(sql, dialect: dialect)
129
- collect_join_identifiers(stripped, results)
130
- collect_from_identifiers(stripped, results)
160
+ views = SqlNoiseStripper.security_views(sql, dialect: dialect, mysql_modes: mysql_modes)
161
+ views.flat_map { |stripped| executable_comment_views(stripped) }.each do |view|
162
+ collect_join_identifiers(view, results)
163
+ collect_from_identifiers(view, results)
164
+ collect_table_statement_identifiers(view, results)
165
+ end
131
166
  end
132
167
  results.uniq
133
168
  end
134
169
 
135
170
  # Table-factor prefixes, retaining commas after balanced subqueries and
136
171
  # JOIN predicates. Each nested FROM/JOIN is also scanned independently.
172
+ # SQL punctuation separates tokens without mandatory whitespace. Keep
173
+ # compact quoted targets and parenthesized relations in the same policy.
137
174
  # @param sql [String] noise-stripped SQL
138
175
  # @return [Array<String>]
139
176
  def self.relation_factors(sql)
140
- sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
177
+ sql.to_enum(:scan, /\b(?:FROM|(?:STRAIGHT_)?JOIN)(?=[\s("`])\s*/i).flat_map do
141
178
  suffix = sql[Regexp.last_match.end(0)..]
142
179
  split_top_level_commas(relation_clause(suffix))
143
180
  end
@@ -169,21 +206,36 @@ module Woods
169
206
  return false unless token.match?(/\A[A-Za-z]/)
170
207
  return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
171
208
  return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
172
- return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
209
+ return rest.match?(/\A\s+[A-Za-z0-9_$\u0080-\u{10ffff}]+\s+AS\b/i) if token.casecmp?('WINDOW')
173
210
 
174
211
  true
175
212
  end
176
213
  private_class_method :relation_keyword_boundary?
177
214
 
178
215
  # @api private
179
- # Comments and literals must be stripped in a single combined pass —
180
- # stripping them separately lets a comment marker inside a literal
181
- # (`'-- '`) hide a real FROM clause from the gate. See
182
- # {SqlNoiseStripper.strip_noise}.
183
- def self.strip_noise(sql, dialect:)
184
- SqlNoiseStripper.strip_noise(sql, dialect: dialect)
216
+ # Every view of the stripped SQL the FROM/JOIN scans must consider for
217
+ # MySQL executable comments (`/*! ... */`). {SqlNoiseStripper} leaves
218
+ # these forms in place because MySQL executes their body, but the lead
219
+ # grammars (FROM_CLAUSE/LEAD_IDENT/JOIN_REFERENCE) cannot start on a
220
+ # comment marker, so `SELECT * FROM /*!authorizations*/` surfaced no
221
+ # identifier and a blocked table slipped past TableGate. Each view
222
+ # interprets the form under one of its two possible semantics, mirroring
223
+ # SqlValidator#lock_clause_views: the whole construct replaced by its
224
+ # body (version guard satisfied — the body executes in place) and the
225
+ # whole construct dropped (guard unsatisfied — the form is inert
226
+ # whitespace). The stripped text itself is always scanned too: it keeps
227
+ # the preserved-form behavior the post-comma executable-comment shape
228
+ # relies on, and no comment body is ever hidden from the union.
229
+ #
230
+ # Over-detection on PostgreSQL (where the `/*!` form is a syntax error)
231
+ # is acceptable: the gate may reject more than a server would execute,
232
+ # never less.
233
+ def self.executable_comment_views(stripped)
234
+ [stripped,
235
+ stripped.gsub(EXECUTABLE_COMMENT_PATTERN) { Regexp.last_match[1] },
236
+ stripped.gsub(EXECUTABLE_COMMENT_PATTERN, ' ')]
185
237
  end
186
- private_class_method :strip_noise
238
+ private_class_method :executable_comment_views
187
239
 
188
240
  # @api private
189
241
  def self.collect_join_identifiers(sql, results)
@@ -203,6 +255,16 @@ module Woods
203
255
  end
204
256
  private_class_method :collect_from_identifiers
205
257
 
258
+ # @api private
259
+ # Collect identifiers referenced by the standalone `TABLE name`
260
+ # statement (see {TABLE_STATEMENT}).
261
+ def self.collect_table_statement_identifiers(sql, results)
262
+ sql.scan(TABLE_STATEMENT) do
263
+ results << qualified_identifier(Regexp.last_match)
264
+ end
265
+ end
266
+ private_class_method :collect_table_statement_identifiers
267
+
206
268
  # @api private
207
269
  # Split a comma-separated list at depth 0, skipping commas inside parens.
208
270
  def self.split_top_level_commas(clause) # rubocop:disable Metrics/MethodLength
@@ -239,8 +301,9 @@ module Woods
239
301
  # PostgreSQL `ONLY` inheritance keyword is stripped first so it does
240
302
  # not hide the table.
241
303
  def self.lead_identifier(chunk)
242
- stripped = chunk.to_s.strip.sub(ONLY_PREFIX, '')
243
- return nil if stripped.empty?
304
+ stripped = chunk.to_s.strip.sub(/\A(?:\(\s*)+/, '').sub(ONLY_PREFIX, '')
305
+ stripped = stripped.sub(/\A(?:\(\s*)+/, '')
306
+ return nil if stripped.empty? || stripped.match?(/\A(?:SELECT|WITH|TABLE)\b/i)
244
307
 
245
308
  match = LEAD_IDENT.match(stripped)
246
309
  return nil unless match
@@ -253,14 +316,22 @@ module Woods
253
316
  # Combine a schema prefix with the table identifier captured by
254
317
  # JOIN_REFERENCE / LEAD_IDENT into a single `schema.table` string.
255
318
  def self.qualified_identifier(match)
256
- table = match[:backtick] || match[:double] || match[:bare]
257
- schema = match.named_captures.values_at(
258
- 'schema_bt', 'schema_dq', 'schema_bare',
259
- 'jschema_bt', 'jschema_dq', 'jschema_bare'
260
- ).compact.first
319
+ captures = match.named_captures
320
+ table = decoded_identifier(*captures.values_at('backtick', 'double', 'bare'))
321
+ schema = decoded_identifier(captures['schema_bt'] || captures['jschema_bt'],
322
+ captures['schema_dq'] || captures['jschema_dq'],
323
+ captures['schema_bare'] || captures['jschema_bare'])
261
324
  schema ? "#{schema}.#{table}" : table
262
325
  end
263
326
  private_class_method :qualified_identifier
327
+
328
+ def self.decoded_identifier(backtick, double, bare)
329
+ return backtick.gsub('``', '`') if backtick
330
+ return double.gsub('""', '"') if double
331
+
332
+ bare
333
+ end
334
+ private_class_method :decoded_identifier
264
335
  end
265
336
  end
266
337
  end
@@ -24,7 +24,7 @@ module Woods
24
24
  # validator.validate!('DELETE FROM users') # raises SqlValidationError
25
25
  # validator.valid?('SELECT 1') # => true
26
26
  #
27
- class SqlValidator
27
+ class SqlValidator # rubocop:disable Metrics/ClassLength -- dialect views and delimiter checks supplement legacy validation
28
28
  # Forbidden statement prefixes (case-insensitive).
29
29
  #
30
30
  # Expanded beyond DML/DDL to cover:
@@ -97,15 +97,20 @@ module Woods
97
97
  end.freeze
98
98
 
99
99
  # @param dialect [Symbol, nil] Known connection dialect, when available
100
- def initialize(dialect: nil)
100
+ def initialize(dialect: nil, mysql_modes: nil)
101
101
  @dialect = dialect
102
+ @mysql_modes = mysql_modes
102
103
  end
103
104
 
105
+ KNOWN_DIALECTS = %i[postgres mysql sqlite].freeze
106
+
104
107
  # @raise [SqlValidationError] if the SQL is not a safe read-only statement
105
108
  def validate!(sql)
106
109
  raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
107
110
 
108
111
  normalized = sql.strip
112
+ check_balanced_delimiters!(normalized)
113
+ check_supported_identifier_syntax!(normalized)
109
114
  SqliteReadGuard.validate!(normalized) if @dialect == :sqlite
110
115
 
111
116
  # Reject multiple statements (semicolons not inside string literals)
@@ -147,14 +152,59 @@ module Woods
147
152
 
148
153
  private
149
154
 
155
+ def validation_views(sql)
156
+ dialects = @dialect ? [@dialect] : KNOWN_DIALECTS
157
+ dialects.flat_map do |dialect|
158
+ modes = if dialect == :mysql
159
+ @mysql_modes ? [@mysql_modes] : SqlNoiseStripper::MYSQL_QUOTE_MODES
160
+ else
161
+ [{}]
162
+ end
163
+ modes.map do |mode|
164
+ SqlNoiseStripper.strip_noise(sql, dialect: dialect, **mode) do |comment|
165
+ next unless comment.match?(/['"`$\\]/)
166
+
167
+ raise SqlValidationError,
168
+ 'Rejected: quoted executable comments have ambiguous SQL grammar; use ordinary SQL.'
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ # The policy scanners retain ordinary quoted identifiers but do not
175
+ # decode PostgreSQL Unicode escapes. Refuse that grammar before the
176
+ # adapter can resolve an identifier differently from the policy gates.
177
+ def check_supported_identifier_syntax!(sql)
178
+ return if @dialect && @dialect != :postgres
179
+
180
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: :postgres)
181
+ tokens = stripped.scan(/"(?:[^"]|"")*"|(?<![A-Za-z0-9_$\u0080-\u{10ffff}])[uU]&"/)
182
+ return unless tokens.any? { |token| token.start_with?('U&"', 'u&"') }
183
+
184
+ raise SqlValidationError,
185
+ 'Rejected: PostgreSQL escaped identifiers are unsupported; use ordinary quoted identifiers ' \
186
+ 'or a structured Console tool.'
187
+ end
188
+
189
+ def check_balanced_delimiters!(sql)
190
+ validation_views(sql).each do |stripped|
191
+ depth = 0
192
+ stripped.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]/).each do |token|
193
+ depth += 1 if token == '('
194
+ depth -= 1 if token == ')'
195
+ raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' if depth.negative?
196
+ end
197
+ raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' unless depth.zero?
198
+ end
199
+ end
200
+
150
201
  # Check if the SQL contains multiple statements separated by semicolons.
151
202
  # Strips SQL comments and string literals before checking.
152
203
  #
153
204
  # @param sql [String]
154
205
  # @return [Boolean]
155
206
  def contains_multiple_statements?(sql)
156
- stripped = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
157
- stripped.include?(';')
207
+ validation_views(sql).any? { |stripped| stripped.include?(';') }
158
208
  end
159
209
 
160
210
  # Check if the SQL starts with a forbidden keyword.
@@ -192,7 +242,12 @@ module Woods
192
242
  # @param sql [String]
193
243
  # @raise [SqlValidationError] if a dangerous function is found
194
244
  def check_dangerous_functions!(sql)
195
- view = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
245
+ validation_views(sql).each do |view|
246
+ check_dangerous_functions_in_view!(view)
247
+ end
248
+ end
249
+
250
+ def check_dangerous_functions_in_view!(view)
196
251
  view.scan(/(?:"([^"\n]+)"|`([^`]+)`|\b([a-z_][a-z0-9_]*))\s*\(/i) do |quoted, backtick, bare|
197
252
  func = (quoted || backtick || bare).downcase
198
253
  next unless DANGEROUS_FUNCTIONS.include?(func)
@@ -16,23 +16,31 @@ module Woods
16
16
  # Matches OWASP "session ID entropy" guidance (>= 128 bits ≈ 32 hex chars).
17
17
  MIN_TOKEN_LENGTH = 32
18
18
 
19
- def initialize(app, token:)
20
- raise ArgumentError, 'token must be a non-empty string' if token.nil? || token.empty?
21
- if token.to_s.length < MIN_TOKEN_LENGTH
22
- raise ArgumentError,
23
- "bearer token must be at least #{MIN_TOKEN_LENGTH} characters " \
24
- "(got #{token.to_s.length}); generate with `SecureRandom.hex(32)`"
25
- end
19
+ # Rails 6 forwards middleware keywords as a positional options Hash.
20
+ def initialize(app, options = {}, **keywords)
21
+ raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
22
+
23
+ initialize_options(app, **options, **keywords)
24
+ end
26
25
 
26
+ def initialize_options(app, token:, path: nil)
27
27
  @app = app
28
- @token = token.to_s
28
+ @path = path
29
+ @token = token
30
+ validate_token!(token) unless token.respond_to?(:call)
31
+ @token = token.to_s unless token.respond_to?(:call)
29
32
  end
33
+ private :initialize_options
30
34
 
31
35
  def call(env)
36
+ return @app.call(env) if @path && !env['PATH_INFO'].to_s.start_with?(@path)
37
+
38
+ token = @token.respond_to?(:call) ? @token.call : @token
32
39
  header = env['HTTP_AUTHORIZATION'].to_s
33
- presented = header.start_with?('Bearer ') ? header.sub(/\ABearer /, '') : nil
40
+ presented = header.b.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header.byteslice(7..) : nil
34
41
 
35
- if presented && Rack::Utils.secure_compare(@token, presented)
42
+ if token.is_a?(String) && token.length >= MIN_TOKEN_LENGTH && presented &&
43
+ Rack::Utils.secure_compare(token, presented)
36
44
  @app.call(env)
37
45
  else
38
46
  [401,
@@ -40,6 +48,17 @@ module Woods
40
48
  [UNAUTHORIZED_BODY]]
41
49
  end
42
50
  end
51
+
52
+ private
53
+
54
+ def validate_token!(token)
55
+ raise ArgumentError, 'token must be a non-empty string' if token.nil? || token.to_s.empty?
56
+ return unless token.to_s.length < MIN_TOKEN_LENGTH
57
+
58
+ raise ArgumentError,
59
+ "bearer token must be at least #{MIN_TOKEN_LENGTH} characters " \
60
+ "(got #{token.to_s.length}); generate with `SecureRandom.hex(32)`"
61
+ end
43
62
  end
44
63
  end
45
64
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'uri'
3
+ require_relative 'origin_policy'
4
4
 
5
5
  module Woods
6
6
  module MCP
@@ -8,16 +8,7 @@ module Woods
8
8
  # Empty configuration retains the SDK's strict loopback defaults.
9
9
  module HttpTransportOptions
10
10
  def self.for(origins)
11
- normalized = Array(origins).map { |origin| origin.strip.downcase.delete_suffix('/') }.reject(&:empty?)
12
- return {} if normalized.empty?
13
-
14
- hosts = normalized.filter_map do |origin|
15
- uri = URI.parse(origin)
16
- uri.host if %w[http https].include?(uri.scheme)
17
- rescue URI::InvalidURIError
18
- nil
19
- end
20
- { allowed_origins: normalized, allowed_hosts: hosts.uniq }
11
+ OriginPolicy.new(allowed_origins: origins).transport_options
21
12
  end
22
13
  end
23
14
  end
@@ -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