otto 1.5.0 → 1.6.0
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/.github/workflows/ci.yml +43 -4
- data/.rubocop.yml +1 -1
- data/Gemfile +12 -3
- data/Gemfile.lock +51 -8
- data/bin/rspec +16 -0
- data/examples/mcp_demo/app.rb +56 -0
- data/examples/mcp_demo/config.ru +68 -0
- data/examples/mcp_demo/routes +9 -0
- data/lib/concurrent_cache_store.rb +68 -0
- data/lib/otto/helpers/validation.rb +83 -0
- data/lib/otto/mcp/auth/token.rb +76 -0
- data/lib/otto/mcp/protocol.rb +167 -0
- data/lib/otto/mcp/rate_limiting.rb +150 -0
- data/lib/otto/mcp/registry.rb +95 -0
- data/lib/otto/mcp/route_parser.rb +82 -0
- data/lib/otto/mcp/server.rb +196 -0
- data/lib/otto/mcp/validation.rb +119 -0
- data/lib/otto/route_definition.rb +15 -15
- data/lib/otto/route_handlers.rb +126 -97
- data/lib/otto/security/config.rb +3 -1
- data/lib/otto/security/rate_limiting.rb +111 -0
- data/lib/otto/security/validator.rb +35 -74
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +127 -1
- data/otto.gemspec +11 -6
- metadata +67 -19
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0acf8247a8132f9e836ba841732a45bf265efbc0cae190c744e323a97229d913
|
4
|
+
data.tar.gz: 05fe0d3c74d9385e470ea45856e0c8cb2b081ff6eea63a83951039bd426219b3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1f86dd56ef78a8f892d4e75046f2ac4560e951999773b668dba6a15a270d4f811aa0df56f618c0a8b1ce75af7b76799ca083b0b30788cc5733cc570107233f64
|
7
|
+
data.tar.gz: 6fd7fcc1008ed682dbcc7a4e400a220c55a8f1cd9d3081e7d089e761c14f883d11028ad1b0e279d7ea96c33e2ff4fc6afb0aabf69356d0c42ba8527eb2e4627d
|
data/.github/workflows/ci.yml
CHANGED
@@ -8,6 +8,12 @@ on:
|
|
8
8
|
pull_request:
|
9
9
|
|
10
10
|
workflow_dispatch:
|
11
|
+
inputs:
|
12
|
+
debug_enabled:
|
13
|
+
type: boolean
|
14
|
+
description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)"
|
15
|
+
required: false
|
16
|
+
default: false
|
11
17
|
|
12
18
|
permissions:
|
13
19
|
contents: read
|
@@ -17,10 +23,19 @@ jobs:
|
|
17
23
|
timeout-minutes: 10
|
18
24
|
runs-on: ubuntu-24.04
|
19
25
|
name: "RSpec Tests (Ruby ${{ matrix.ruby }})"
|
26
|
+
continue-on-error: ${{ matrix.experimental }}
|
20
27
|
strategy:
|
21
|
-
fail-fast:
|
28
|
+
fail-fast: false
|
22
29
|
matrix:
|
23
|
-
|
30
|
+
include:
|
31
|
+
- ruby: "3.2"
|
32
|
+
experimental: false
|
33
|
+
- ruby: "3.3"
|
34
|
+
experimental: false
|
35
|
+
- ruby: "3.4"
|
36
|
+
experimental: false
|
37
|
+
- ruby: "3.5"
|
38
|
+
experimental: true
|
24
39
|
|
25
40
|
steps:
|
26
41
|
- uses: actions/checkout@v4
|
@@ -30,5 +45,29 @@ jobs:
|
|
30
45
|
ruby-version: ${{ matrix.ruby }}
|
31
46
|
bundler-cache: true
|
32
47
|
|
33
|
-
- name:
|
34
|
-
|
48
|
+
- name: Setup tmate session
|
49
|
+
uses: mxschmitt/action-tmate@7b6a61a73bbb9793cb80ad69b8dd8ac19261834c # v3
|
50
|
+
if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
|
51
|
+
with:
|
52
|
+
detached: true
|
53
|
+
|
54
|
+
- name: Install dependencies
|
55
|
+
continue-on-error: ${{ matrix.experimental }}
|
56
|
+
run: |
|
57
|
+
bundle config path vendor/bundle
|
58
|
+
bundle install --jobs 4 --retry 3 --with test
|
59
|
+
|
60
|
+
- name: Verify setup
|
61
|
+
run: |
|
62
|
+
bundle exec which rspec || echo "rspec not found"
|
63
|
+
bundle list | grep -E "(rspec|rake|test)"
|
64
|
+
ls -la bin/ || echo "No bin directory"
|
65
|
+
|
66
|
+
- name: Run test specs
|
67
|
+
env:
|
68
|
+
REDIS_URL: "redis://127.0.0.1:2121/0"
|
69
|
+
run: |
|
70
|
+
mkdir tmp && bundle exec rspec \
|
71
|
+
--format progress \
|
72
|
+
--format json \
|
73
|
+
--out tmp/rspec_results.json
|
data/.rubocop.yml
CHANGED
data/Gemfile
CHANGED
@@ -1,13 +1,22 @@
|
|
1
|
+
# Gemfile
|
2
|
+
|
1
3
|
source 'https://rubygems.org'
|
2
4
|
|
3
5
|
gemspec
|
4
6
|
|
5
|
-
group :
|
7
|
+
group :test do
|
6
8
|
gem 'rack-test'
|
7
9
|
gem 'rspec', '~> 3.12'
|
8
10
|
end
|
9
11
|
|
10
|
-
|
12
|
+
# bundle config set with 'optional'
|
13
|
+
group :development, :test, optional: true do
|
14
|
+
# Keep gems that need to be in both environments
|
15
|
+
gem 'json_schemer'
|
16
|
+
gem 'rack-attack'
|
17
|
+
end
|
18
|
+
|
19
|
+
group :development do
|
11
20
|
gem 'pry-byebug', require: false
|
12
21
|
gem 'rubocop', require: false
|
13
22
|
gem 'rubocop-performance', require: false
|
@@ -16,5 +25,5 @@ group 'development' do
|
|
16
25
|
gem 'ruby-lsp', require: false
|
17
26
|
gem 'stackprof', require: false
|
18
27
|
gem 'syntax_tree', require: false
|
19
|
-
gem 'tryouts', '~> 3.3.
|
28
|
+
gem 'tryouts', '~> 3.3.2', require: false
|
20
29
|
end
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
otto (1.
|
4
|
+
otto (1.6.0)
|
5
|
+
facets (~> 3.1)
|
6
|
+
loofah (~> 2.20)
|
5
7
|
ostruct
|
6
8
|
rack (~> 3.1, < 4.0)
|
7
9
|
rack-parser (~> 0.7)
|
@@ -11,22 +13,51 @@ GEM
|
|
11
13
|
remote: https://rubygems.org/
|
12
14
|
specs:
|
13
15
|
ast (2.4.3)
|
16
|
+
bigdecimal (3.2.2)
|
14
17
|
byebug (12.0.0)
|
15
18
|
coderay (1.1.3)
|
19
|
+
concurrent-ruby (1.3.5)
|
20
|
+
crass (1.0.6)
|
16
21
|
date (3.4.1)
|
17
22
|
diff-lcs (1.6.2)
|
18
23
|
erb (5.0.2)
|
24
|
+
facets (3.1.0)
|
25
|
+
hana (1.3.7)
|
19
26
|
io-console (0.8.1)
|
20
27
|
irb (1.15.2)
|
21
28
|
pp (>= 0.6.0)
|
22
29
|
rdoc (>= 4.0.0)
|
23
30
|
reline (>= 0.4.2)
|
24
31
|
json (2.13.2)
|
32
|
+
json_schemer (2.4.0)
|
33
|
+
bigdecimal
|
34
|
+
hana (~> 1.3)
|
35
|
+
regexp_parser (~> 2.0)
|
36
|
+
simpleidn (~> 0.2)
|
25
37
|
language_server-protocol (3.17.0.5)
|
26
38
|
lint_roller (1.1.0)
|
27
39
|
logger (1.7.0)
|
40
|
+
loofah (2.24.1)
|
41
|
+
crass (~> 1.0.2)
|
42
|
+
nokogiri (>= 1.12.0)
|
28
43
|
method_source (1.1.0)
|
29
44
|
minitest (5.25.5)
|
45
|
+
nokogiri (1.18.9-aarch64-linux-gnu)
|
46
|
+
racc (~> 1.4)
|
47
|
+
nokogiri (1.18.9-aarch64-linux-musl)
|
48
|
+
racc (~> 1.4)
|
49
|
+
nokogiri (1.18.9-arm-linux-gnu)
|
50
|
+
racc (~> 1.4)
|
51
|
+
nokogiri (1.18.9-arm-linux-musl)
|
52
|
+
racc (~> 1.4)
|
53
|
+
nokogiri (1.18.9-arm64-darwin)
|
54
|
+
racc (~> 1.4)
|
55
|
+
nokogiri (1.18.9-x86_64-darwin)
|
56
|
+
racc (~> 1.4)
|
57
|
+
nokogiri (1.18.9-x86_64-linux-gnu)
|
58
|
+
racc (~> 1.4)
|
59
|
+
nokogiri (1.18.9-x86_64-linux-musl)
|
60
|
+
racc (~> 1.4)
|
30
61
|
ostruct (0.6.3)
|
31
62
|
parallel (1.27.0)
|
32
63
|
parser (3.3.9.0)
|
@@ -50,6 +81,8 @@ GEM
|
|
50
81
|
stringio
|
51
82
|
racc (1.8.1)
|
52
83
|
rack (3.2.0)
|
84
|
+
rack-attack (6.7.0)
|
85
|
+
rack (>= 1.0, < 4)
|
53
86
|
rack-parser (0.7.0)
|
54
87
|
rack
|
55
88
|
rack-test (2.2.0)
|
@@ -60,7 +93,7 @@ GEM
|
|
60
93
|
rdoc (6.14.2)
|
61
94
|
erb
|
62
95
|
psych (>= 4.0.0)
|
63
|
-
regexp_parser (2.11.
|
96
|
+
regexp_parser (2.11.2)
|
64
97
|
reline (0.6.2)
|
65
98
|
io-console (~> 0.5)
|
66
99
|
rexml (3.4.1)
|
@@ -77,7 +110,7 @@ GEM
|
|
77
110
|
diff-lcs (>= 1.2.0, < 2.0)
|
78
111
|
rspec-support (~> 3.13.0)
|
79
112
|
rspec-support (3.13.4)
|
80
|
-
rubocop (1.79.
|
113
|
+
rubocop (1.79.2)
|
81
114
|
json (~> 2.3)
|
82
115
|
language_server-protocol (~> 3.17.0.2)
|
83
116
|
lint_roller (~> 1.1.0)
|
@@ -107,11 +140,13 @@ GEM
|
|
107
140
|
prism (>= 1.2, < 2.0)
|
108
141
|
rbs (>= 3, < 5)
|
109
142
|
ruby-progressbar (1.13.0)
|
143
|
+
simpleidn (0.2.3)
|
110
144
|
stackprof (0.2.27)
|
111
145
|
stringio (3.1.7)
|
112
146
|
syntax_tree (6.3.0)
|
113
147
|
prettier_print (>= 1.2.0)
|
114
|
-
tryouts (3.3.
|
148
|
+
tryouts (3.3.2)
|
149
|
+
concurrent-ruby (~> 1.0)
|
115
150
|
irb
|
116
151
|
minitest (~> 5.0)
|
117
152
|
pastel (~> 0.8)
|
@@ -127,12 +162,20 @@ GEM
|
|
127
162
|
unicode-emoji (4.0.4)
|
128
163
|
|
129
164
|
PLATFORMS
|
130
|
-
|
131
|
-
|
165
|
+
aarch64-linux-gnu
|
166
|
+
aarch64-linux-musl
|
167
|
+
arm-linux-gnu
|
168
|
+
arm-linux-musl
|
169
|
+
arm64-darwin
|
170
|
+
x86_64-darwin
|
171
|
+
x86_64-linux-gnu
|
172
|
+
x86_64-linux-musl
|
132
173
|
|
133
174
|
DEPENDENCIES
|
175
|
+
json_schemer
|
134
176
|
otto!
|
135
177
|
pry-byebug
|
178
|
+
rack-attack
|
136
179
|
rack-test
|
137
180
|
rspec (~> 3.12)
|
138
181
|
rubocop
|
@@ -142,7 +185,7 @@ DEPENDENCIES
|
|
142
185
|
ruby-lsp
|
143
186
|
stackprof
|
144
187
|
syntax_tree
|
145
|
-
tryouts (~> 3.3.
|
188
|
+
tryouts (~> 3.3.2)
|
146
189
|
|
147
190
|
BUNDLED WITH
|
148
|
-
2.
|
191
|
+
2.7.1
|
data/bin/rspec
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
require "rubygems"
|
14
|
+
require "bundler/setup"
|
15
|
+
|
16
|
+
load Gem.bin_path("rspec-core", "rspec")
|
@@ -0,0 +1,56 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# Example Otto application with MCP support
|
4
|
+
# This demonstrates Phase 1 & 2 implementation
|
5
|
+
|
6
|
+
require_relative '../../lib/otto'
|
7
|
+
|
8
|
+
class UserAPI
|
9
|
+
def self.mcp_list_users
|
10
|
+
{
|
11
|
+
users: [
|
12
|
+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
|
13
|
+
{ id: 2, name: 'Bob', email: 'bob@example.com' }
|
14
|
+
]
|
15
|
+
}.to_json
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.mcp_create_user(arguments, env)
|
19
|
+
# Tool handler that creates a user
|
20
|
+
name = arguments['name'] || 'Anonymous'
|
21
|
+
email = arguments['email'] || "#{name.downcase}@example.com"
|
22
|
+
|
23
|
+
new_user = {
|
24
|
+
id: rand(1000..9999),
|
25
|
+
name: name,
|
26
|
+
email: email,
|
27
|
+
created_at: Time.now.iso8601
|
28
|
+
}
|
29
|
+
|
30
|
+
"Created user: #{new_user.to_json}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Initialize Otto with MCP support
|
35
|
+
otto = Otto.new('routes', {
|
36
|
+
mcp_enabled: true,
|
37
|
+
auth_tokens: ['demo-token-123'], # Simple token auth
|
38
|
+
requests_per_minute: 10, # Lower for demo
|
39
|
+
tools_per_minute: 5
|
40
|
+
})
|
41
|
+
|
42
|
+
# Enable MCP with authentication tokens
|
43
|
+
otto.enable_mcp!({
|
44
|
+
auth_tokens: ['demo-token-123', 'another-token-456'],
|
45
|
+
enable_validation: true,
|
46
|
+
enable_rate_limiting: true
|
47
|
+
})
|
48
|
+
|
49
|
+
puts "Otto MCP Demo Server starting..."
|
50
|
+
puts "MCP endpoint: POST /_mcp"
|
51
|
+
puts "Auth tokens: demo-token-123, another-token-456"
|
52
|
+
puts "Usage: curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\"
|
53
|
+
puts " -d '{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":1,\"params\":{}}' \\"
|
54
|
+
puts " http://localhost:9292/_mcp"
|
55
|
+
|
56
|
+
otto
|
@@ -0,0 +1,68 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../../lib/otto'
|
4
|
+
|
5
|
+
class DemoApp
|
6
|
+
def self.index(req, res)
|
7
|
+
res.body = <<-HTML
|
8
|
+
<h1>Otto MCP Demo</h1>
|
9
|
+
<p>MCP endpoint available at: <code>POST /_mcp</code></p>
|
10
|
+
<p>Auth tokens: <code>demo-token-123</code>, <code>another-token-456</code></p>
|
11
|
+
|
12
|
+
<h2>Test MCP Initialize</h2>
|
13
|
+
<pre>curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\
|
14
|
+
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' \\
|
15
|
+
http://localhost:9292/_mcp</pre>
|
16
|
+
|
17
|
+
<h2>List Resources</h2>
|
18
|
+
<pre>curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\
|
19
|
+
-d '{"jsonrpc":"2.0","method":"resources/list","id":2}' \\
|
20
|
+
http://localhost:9292/_mcp</pre>
|
21
|
+
|
22
|
+
<h2>List Tools</h2>
|
23
|
+
<pre>curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\
|
24
|
+
-d '{"jsonrpc":"2.0","method":"tools/list","id":3}' \\
|
25
|
+
http://localhost:9292/_mcp</pre>
|
26
|
+
HTML
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.health(req, res)
|
30
|
+
res.body = 'OK'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class UserAPI
|
35
|
+
def self.mcp_list_users
|
36
|
+
{
|
37
|
+
users: [
|
38
|
+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
|
39
|
+
{ id: 2, name: 'Bob', email: 'bob@example.com' }
|
40
|
+
]
|
41
|
+
}.to_json
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.mcp_create_user(arguments, env)
|
45
|
+
# Tool handler that creates a user
|
46
|
+
name = arguments['name'] || 'Anonymous'
|
47
|
+
email = arguments['email'] || "#{name.downcase}@example.com"
|
48
|
+
|
49
|
+
new_user = {
|
50
|
+
id: rand(1000..9999),
|
51
|
+
name: name,
|
52
|
+
email: email,
|
53
|
+
created_at: Time.now.iso8601
|
54
|
+
}
|
55
|
+
|
56
|
+
"Created user: #{new_user.to_json}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Initialize Otto with MCP support
|
61
|
+
otto = Otto.new('routes', {
|
62
|
+
mcp_enabled: true,
|
63
|
+
auth_tokens: ['demo-token-123', 'another-token-456'],
|
64
|
+
requests_per_minute: 60,
|
65
|
+
tools_per_minute: 20
|
66
|
+
})
|
67
|
+
|
68
|
+
run otto
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# lib/concurrent_cache_store.rb
|
2
|
+
|
3
|
+
require 'concurrent-ruby'
|
4
|
+
|
5
|
+
# Thread-safe cache store with TTL support for Rack::Attack
|
6
|
+
# Provides ActiveSupport::Cache::MemoryStore-compatible interface
|
7
|
+
#
|
8
|
+
# Usage:
|
9
|
+
#
|
10
|
+
# Rack::Attack.cache.store = ConcurrentCacheStore.new(default_ttl: 300)
|
11
|
+
#
|
12
|
+
class ConcurrentCacheStore
|
13
|
+
# @param default_ttl [Integer] Default time-to-live in seconds for cache entries
|
14
|
+
def initialize(default_ttl: 300)
|
15
|
+
@store = Concurrent::Map.new
|
16
|
+
@default_ttl = default_ttl
|
17
|
+
end
|
18
|
+
|
19
|
+
# Retrieves a value from the cache
|
20
|
+
# @param key [String] The cache key
|
21
|
+
# @return [Object, nil] The cached value or nil if expired/missing
|
22
|
+
def read(key)
|
23
|
+
entry = @store[key]
|
24
|
+
return nil unless entry
|
25
|
+
|
26
|
+
if Time.now > entry[:expires_at]
|
27
|
+
@store.delete(key)
|
28
|
+
nil
|
29
|
+
else
|
30
|
+
entry[:value]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Stores a value in the cache with expiration
|
35
|
+
# @param key [String] The cache key
|
36
|
+
# @param value [Object] The value to store
|
37
|
+
# @param expires_in [Integer] TTL in seconds (optional)
|
38
|
+
# @return [Object] The stored value
|
39
|
+
def write(key, value, expires_in: @default_ttl)
|
40
|
+
@store[key] = {
|
41
|
+
value: value,
|
42
|
+
expires_at: Time.now + expires_in,
|
43
|
+
}
|
44
|
+
value
|
45
|
+
end
|
46
|
+
|
47
|
+
# Atomically increments a numeric value, creating if missing
|
48
|
+
# @param key [String] The cache key
|
49
|
+
# @param amount [Integer] Amount to increment by
|
50
|
+
# @param expires_in [Integer] TTL in seconds for new entries
|
51
|
+
# @return [Integer] The new value after increment
|
52
|
+
def increment(key, amount = 1, expires_in: @default_ttl)
|
53
|
+
@store.compute(key) do |_, entry|
|
54
|
+
if entry && Time.now <= entry[:expires_at]
|
55
|
+
entry[:value] += amount
|
56
|
+
entry
|
57
|
+
else
|
58
|
+
{ value: amount, expires_at: Time.now + expires_in }
|
59
|
+
end
|
60
|
+
end[:value]
|
61
|
+
end
|
62
|
+
|
63
|
+
# Removes all entries from the cache
|
64
|
+
# @return [void]
|
65
|
+
def clear
|
66
|
+
@store.clear
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# lib/otto/helpers/validation.rb
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
module Security
|
5
|
+
module ValidationHelpers
|
6
|
+
def validate_input(input, max_length: 1000, allow_html: false)
|
7
|
+
return input if input.nil?
|
8
|
+
|
9
|
+
input_str = input.to_s
|
10
|
+
return input_str if input_str.empty?
|
11
|
+
|
12
|
+
# Check length
|
13
|
+
if input_str.length > max_length
|
14
|
+
raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
|
15
|
+
end
|
16
|
+
|
17
|
+
# Use Loofah for HTML sanitization and validation
|
18
|
+
unless allow_html
|
19
|
+
# Check for script injection first (these should always be rejected)
|
20
|
+
if looks_like_script_injection?(input_str)
|
21
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected'
|
22
|
+
end
|
23
|
+
|
24
|
+
# Use Loofah to sanitize less dangerous HTML content
|
25
|
+
sanitized_input = Loofah.fragment(input_str).scrub!(:whitewash).to_s
|
26
|
+
input_str = sanitized_input
|
27
|
+
end
|
28
|
+
|
29
|
+
# Always check for SQL injection
|
30
|
+
ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
|
31
|
+
if input_str.match?(pattern)
|
32
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
input_str
|
37
|
+
end
|
38
|
+
|
39
|
+
def sanitize_filename(filename)
|
40
|
+
return nil if filename.nil?
|
41
|
+
return 'file' if filename.empty?
|
42
|
+
|
43
|
+
# Use Facets File.sanitize for basic filesystem-safe filename
|
44
|
+
clean_name = File.sanitize(filename.to_s)
|
45
|
+
|
46
|
+
# Handle edge cases and improve on Facets behavior to match test expectations
|
47
|
+
if clean_name.nil? || clean_name.empty?
|
48
|
+
clean_name = 'file'
|
49
|
+
else
|
50
|
+
# Additional cleanup that Facets doesn't do but our tests expect
|
51
|
+
clean_name = clean_name.gsub(/_{2,}/, '_') # Collapse multiple underscores
|
52
|
+
clean_name = clean_name.gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
|
53
|
+
clean_name = 'file' if clean_name.empty? # Handle case where only underscores remain
|
54
|
+
end
|
55
|
+
|
56
|
+
# Ensure reasonable length (255 is filesystem limit, leave some padding)
|
57
|
+
clean_name = clean_name[0..99] if clean_name.length > 100
|
58
|
+
|
59
|
+
clean_name
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# Check if content looks like it contains HTML tags or entities
|
65
|
+
def contains_html_like_content?(content)
|
66
|
+
content.match?(/[<>&]/) || content.match?(/&\w+;/)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Detect likely script injection attempts that should be rejected
|
70
|
+
def looks_like_script_injection?(content)
|
71
|
+
dangerous_patterns = [
|
72
|
+
/javascript:/i,
|
73
|
+
/<script[^>]*>/i,
|
74
|
+
/on\w+\s*=/i, # event handlers like onclick=
|
75
|
+
/expression\s*\(/i,
|
76
|
+
/data:.*base64/i,
|
77
|
+
]
|
78
|
+
|
79
|
+
dangerous_patterns.any? { |pattern| content.match?(pattern) }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
module MCP
|
5
|
+
module Auth
|
6
|
+
class TokenAuth
|
7
|
+
def initialize(tokens)
|
8
|
+
@tokens = Array(tokens).to_set
|
9
|
+
end
|
10
|
+
|
11
|
+
def authenticate(env)
|
12
|
+
token = extract_token(env)
|
13
|
+
return false unless token
|
14
|
+
|
15
|
+
@tokens.include?(token)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def extract_token(env)
|
21
|
+
# Try Authorization header first (Bearer token)
|
22
|
+
auth_header = env['HTTP_AUTHORIZATION']
|
23
|
+
if auth_header&.start_with?('Bearer ')
|
24
|
+
return auth_header[7..]
|
25
|
+
end
|
26
|
+
|
27
|
+
# Try X-MCP-Token header
|
28
|
+
env['HTTP_X_MCP_TOKEN']
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class TokenMiddleware
|
33
|
+
def initialize(app, security_config = nil)
|
34
|
+
@app = app
|
35
|
+
@security_config = security_config
|
36
|
+
end
|
37
|
+
|
38
|
+
def call(env)
|
39
|
+
# Only apply to MCP endpoints
|
40
|
+
return @app.call(env) unless mcp_endpoint?(env)
|
41
|
+
|
42
|
+
# Get auth instance from security config
|
43
|
+
auth = @security_config&.mcp_auth
|
44
|
+
if auth && !auth.authenticate(env)
|
45
|
+
return unauthorized_response
|
46
|
+
end
|
47
|
+
|
48
|
+
@app.call(env)
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def mcp_endpoint?(env)
|
54
|
+
endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
|
55
|
+
path = env['PATH_INFO'].to_s
|
56
|
+
path.start_with?(endpoint)
|
57
|
+
end
|
58
|
+
|
59
|
+
def unauthorized_response
|
60
|
+
body = JSON.generate({
|
61
|
+
jsonrpc: '2.0',
|
62
|
+
id: nil,
|
63
|
+
error: {
|
64
|
+
code: -32_000,
|
65
|
+
message: 'Unauthorized',
|
66
|
+
data: 'Valid token required',
|
67
|
+
},
|
68
|
+
},
|
69
|
+
)
|
70
|
+
|
71
|
+
[401, { 'content-type' => 'application/json' }, [body]]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|