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.
@@ -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,45 +102,140 @@ 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)
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
- %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)
159
+ (dialect ? [dialect] : %i[postgres mysql]).each do |dialect|
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
 
170
+ # Table-factor prefixes, retaining commas after balanced subqueries and
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.
174
+ # @param sql [String] noise-stripped SQL
175
+ # @return [Array<String>]
176
+ def self.relation_factors(sql)
177
+ sql.to_enum(:scan, /\b(?:FROM|(?:STRAIGHT_)?JOIN)(?=[\s("`])\s*/i).flat_map do
178
+ suffix = sql[Regexp.last_match.end(0)..]
179
+ split_top_level_commas(relation_clause(suffix))
180
+ end
181
+ end
182
+
183
+ # Quoted tokens shield punctuation and clause words; a closing parenthesis
184
+ # at depth zero ends this query's clause, not a nested table expression.
185
+ def self.relation_clause(suffix)
186
+ depth = 0
187
+ tokens = /
188
+ "(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
189
+ \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
190
+ /ix
191
+ suffix.to_enum(:scan, tokens).each do
192
+ token = Regexp.last_match[0]
193
+ start = Regexp.last_match.begin(0)
194
+ finish = Regexp.last_match.end(0)
195
+ boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
196
+ return suffix[0...start] if depth.zero? && boundary
197
+
198
+ depth += 1 if token == '('
199
+ depth -= 1 if token == ')'
200
+ end
201
+ suffix
202
+ end
203
+ private_class_method :relation_clause
204
+
205
+ def self.relation_keyword_boundary?(prefix, rest, token)
206
+ return false unless token.match?(/\A[A-Za-z]/)
207
+ return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
208
+ return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
209
+ return rest.match?(/\A\s+[A-Za-z0-9_$\u0080-\u{10ffff}]+\s+AS\b/i) if token.casecmp?('WINDOW')
210
+
211
+ true
212
+ end
213
+ private_class_method :relation_keyword_boundary?
214
+
135
215
  # @api private
136
- # Comments and literals must be stripped in a single combined pass —
137
- # stripping them separately lets a comment marker inside a literal
138
- # (`'-- '`) hide a real FROM clause from the gate. See
139
- # {SqlNoiseStripper.strip_noise}.
140
- def self.strip_noise(sql, dialect:)
141
- 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, ' ')]
142
237
  end
143
- private_class_method :strip_noise
238
+ private_class_method :executable_comment_views
144
239
 
145
240
  # @api private
146
241
  def self.collect_join_identifiers(sql, results)
@@ -153,23 +248,30 @@ module Woods
153
248
 
154
249
  # @api private
155
250
  def self.collect_from_identifiers(sql, results)
156
- sql.scan(FROM_CLAUSE) do
157
- clause = Regexp.last_match[:clause]
158
- split_top_level_commas(clause).each do |chunk|
159
- ident = lead_identifier(chunk)
160
- results << ident if ident
161
- end
251
+ relation_factors(sql).each do |chunk|
252
+ ident = lead_identifier(chunk)
253
+ results << ident if ident
162
254
  end
163
255
  end
164
256
  private_class_method :collect_from_identifiers
165
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
+
166
268
  # @api private
167
269
  # Split a comma-separated list at depth 0, skipping commas inside parens.
168
270
  def self.split_top_level_commas(clause) # rubocop:disable Metrics/MethodLength
169
271
  depth = 0
170
272
  buf = +''
171
273
  parts = []
172
- clause.each_char do |ch|
274
+ clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
173
275
  case ch
174
276
  when '('
175
277
  depth += 1
@@ -199,8 +301,9 @@ module Woods
199
301
  # PostgreSQL `ONLY` inheritance keyword is stripped first so it does
200
302
  # not hide the table.
201
303
  def self.lead_identifier(chunk)
202
- stripped = chunk.to_s.strip.sub(ONLY_PREFIX, '')
203
- 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)
204
307
 
205
308
  match = LEAD_IDENT.match(stripped)
206
309
  return nil unless match
@@ -213,14 +316,22 @@ module Woods
213
316
  # Combine a schema prefix with the table identifier captured by
214
317
  # JOIN_REFERENCE / LEAD_IDENT into a single `schema.table` string.
215
318
  def self.qualified_identifier(match)
216
- table = match[:backtick] || match[:double] || match[:bare]
217
- schema = match.named_captures.values_at(
218
- 'schema_bt', 'schema_dq', 'schema_bare',
219
- 'jschema_bt', 'jschema_dq', 'jschema_bare'
220
- ).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'])
221
324
  schema ? "#{schema}.#{table}" : table
222
325
  end
223
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
224
335
  end
225
336
  end
226
337
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'woods/console/sql_noise_stripper'
4
+ require 'woods/console/sqlite_read_guard'
4
5
 
5
6
  # @see Woods
6
7
  module Woods
@@ -23,7 +24,7 @@ module Woods
23
24
  # validator.validate!('DELETE FROM users') # raises SqlValidationError
24
25
  # validator.valid?('SELECT 1') # => true
25
26
  #
26
- class SqlValidator
27
+ class SqlValidator # rubocop:disable Metrics/ClassLength -- dialect views and delimiter checks supplement legacy validation
27
28
  # Forbidden statement prefixes (case-insensitive).
28
29
  #
29
30
  # Expanded beyond DML/DDL to cover:
@@ -95,17 +96,22 @@ module Woods
95
96
  [kw, /\b#{kw}\b/i]
96
97
  end.freeze
97
98
 
98
- # Frozen map of dangerous function name => regex matching a call to that function.
99
- # Used by {#check_dangerous_functions!}.
100
- DANGEROUS_FUNCTION_REGEXES = DANGEROUS_FUNCTIONS.to_h do |func|
101
- [func, /\b#{func}\s*\(/i]
102
- end.freeze
99
+ # @param dialect [Symbol, nil] Known connection dialect, when available
100
+ def initialize(dialect: nil, mysql_modes: nil)
101
+ @dialect = dialect
102
+ @mysql_modes = mysql_modes
103
+ end
104
+
105
+ KNOWN_DIALECTS = %i[postgres mysql sqlite].freeze
103
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)
114
+ SqliteReadGuard.validate!(normalized) if @dialect == :sqlite
109
115
 
110
116
  # Reject multiple statements (semicolons not inside string literals)
111
117
  if contains_multiple_statements?(normalized)
@@ -146,14 +152,59 @@ module Woods
146
152
 
147
153
  private
148
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
+
149
201
  # Check if the SQL contains multiple statements separated by semicolons.
150
202
  # Strips SQL comments and string literals before checking.
151
203
  #
152
204
  # @param sql [String]
153
205
  # @return [Boolean]
154
206
  def contains_multiple_statements?(sql)
155
- stripped = SqlNoiseStripper.strip_noise(sql)
156
- stripped.include?(';')
207
+ validation_views(sql).any? { |stripped| stripped.include?(';') }
157
208
  end
158
209
 
159
210
  # Check if the SQL starts with a forbidden keyword.
@@ -191,8 +242,17 @@ module Woods
191
242
  # @param sql [String]
192
243
  # @raise [SqlValidationError] if a dangerous function is found
193
244
  def check_dangerous_functions!(sql)
194
- DANGEROUS_FUNCTION_REGEXES.each do |func, pattern|
195
- raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if sql.match?(pattern)
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)
251
+ view.scan(/(?:"([^"\n]+)"|`([^`]+)`|\b([a-z_][a-z0-9_]*))\s*\(/i) do |quoted, backtick, bare|
252
+ func = (quoted || backtick || bare).downcase
253
+ next unless DANGEROUS_FUNCTIONS.include?(func)
254
+
255
+ raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed"
196
256
  end
197
257
  end
198
258
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods/console/sql_noise_stripper'
4
+ require 'woods/console/sql_table_scanner'
5
+
6
+ module Woods
7
+ module Console
8
+ # Refuse SQLite-specific syntax outside the scanner's supported grammar.
9
+ # This is a conservative read boundary, not a general-purpose SQL parser.
10
+ module SqliteReadGuard
11
+ SIMPLE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
12
+ UNSUPPORTED_SYNTAX = /\b(?:FROM|JOIN)(?=['"`(])/i
13
+ QUOTED_IDENTIFIER = /"(?:[^"]|"")*"|`(?:[^`]|``)*`/
14
+
15
+ # @param sql [String] SQL to execute on SQLite
16
+ # @raise [SqlValidationError] when an identifier or table factor cannot be checked
17
+ # @return [void]
18
+ def self.validate!(sql)
19
+ view = SqlNoiseStripper.strip_noise(sql, dialect: :sqlite)
20
+ refuse! if view.match?(UNSUPPORTED_SYNTAX)
21
+ SqlTableScanner.relation_factors(view).each do |factor|
22
+ factor.scan(QUOTED_IDENTIFIER) { |quoted| refuse! unless quoted[1...-1].match?(SIMPLE_IDENTIFIER) }
23
+ refuse! unless supported_factor?(factor.strip)
24
+ end
25
+ end
26
+
27
+ # A SELECT/WITH subquery has its own independently scanned table factors.
28
+ # Parenthesized table groups and string-quoted names are refused rather
29
+ # than silently omitted from the blocked-table scan.
30
+ def self.supported_factor?(factor)
31
+ return true if factor.match?(/\A\(\s*(?:SELECT|WITH)\b/i)
32
+
33
+ match = SqlTableScanner::LEAD_IDENT.match(factor)
34
+ match && factor[match.end(0)..].match?(/\A(?:\s|,|\)|\z)/)
35
+ end
36
+ private_class_method :supported_factor?
37
+
38
+ def self.refuse!
39
+ raise SqlValidationError,
40
+ 'Rejected: unsupported SQLite identifier or table-reference syntax. ' \
41
+ 'Use simple bare or double-quoted identifiers and SELECT subqueries.'
42
+ end
43
+ private_class_method :refuse!
44
+ end
45
+ end
46
+ end
@@ -32,10 +32,10 @@ module Woods
32
32
 
33
33
  def active? = !(@blocked_bare.empty? && @blocked_qualified.empty?)
34
34
 
35
- def check_sql!(sql)
35
+ def check_sql!(sql, dialect: nil)
36
36
  return unless active? && sql&.length&.positive?
37
37
 
38
- SqlTableScanner.identifiers_in(sql).each do |raw|
38
+ SqlTableScanner.identifiers_in(sql, dialect: dialect).each do |raw|
39
39
  raise TableGateError, reject_message(raw) if blocked?(raw)
40
40
  end
41
41
  end
@@ -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