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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/CONTRIBUTING.md +19 -11
- 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/credential_scanner.rb +2 -1
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +139 -31
- 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 +165 -25
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +163 -52
- data/lib/woods/console/sql_validator.rb +70 -10
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- 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 +9 -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,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
|
|
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)
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
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 :
|
|
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.
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
end
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|