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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +13 -10
- data/README.md +1 -1
- data/exe/woods-console +1 -1
- data/exe/woods-mcp-http +17 -4
- data/lib/woods/cache/cache_middleware.rb +11 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +112 -27
- data/lib/woods/console/model_validator.rb +11 -1
- data/lib/woods/console/rack_middleware.rb +27 -6
- data/lib/woods/console/redactor.rb +46 -14
- data/lib/woods/console/safe_context.rb +56 -8
- data/lib/woods/console/server.rb +2 -1
- data/lib/woods/console/sql_noise_stripper.rb +147 -35
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +117 -46
- data/lib/woods/console/sql_validator.rb +60 -5
- data/lib/woods/mcp/bearer_auth.rb +29 -10
- data/lib/woods/mcp/http_transport_options.rb +2 -11
- data/lib/woods/mcp/origin_guard.rb +25 -51
- data/lib/woods/mcp/origin_policy.rb +113 -0
- data/lib/woods/railtie.rb +11 -4
- data/lib/woods/version.rb +1 -1
- metadata +8 -5
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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+\
|
|
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
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
@
|
|
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.
|
|
40
|
+
presented = header.b.match?(/\A[Bb][Ee][Aa][Rr][Ee][Rr] /) ? header.byteslice(7..) : nil
|
|
34
41
|
|
|
35
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
-
#
|
|
25
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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
|
-
@
|
|
54
|
-
|
|
55
|
-
@
|
|
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
|
|
64
|
-
return forbidden_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
|
|
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
|
|
83
|
+
headers = origin ? cors_headers(origin) : {}
|
|
110
84
|
[204, headers, []]
|
|
111
85
|
end
|
|
112
86
|
|